agent-runtime-py 0.1.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.
agent_runtime/store.py ADDED
@@ -0,0 +1,277 @@
1
+ """Private durable execution state, input receipts, and ordered event cursors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import sqlite3
9
+ import time
10
+ from collections.abc import Iterator
11
+ from contextlib import contextmanager
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ class Store:
17
+ def __init__(self, root: Path) -> None:
18
+ self.root = root.expanduser().resolve()
19
+ self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
20
+ os.chmod(self.root, 0o700)
21
+ self.path = self.root / "runtime.sqlite3"
22
+ with self.connect() as db:
23
+ db.executescript(
24
+ """
25
+ CREATE TABLE IF NOT EXISTS executions (
26
+ id TEXT PRIMARY KEY, session_id TEXT NOT NULL, created REAL NOT NULL,
27
+ value TEXT NOT NULL
28
+ );
29
+ CREATE INDEX IF NOT EXISTS execution_sessions ON executions(session_id,created);
30
+ CREATE TABLE IF NOT EXISTS inputs (
31
+ session_id TEXT NOT NULL, id TEXT NOT NULL, execution_id TEXT NOT NULL,
32
+ digest TEXT NOT NULL, text TEXT NOT NULL, status TEXT NOT NULL,
33
+ created REAL NOT NULL, error TEXT, native_uuid TEXT,
34
+ PRIMARY KEY(session_id,id)
35
+ );
36
+ CREATE TABLE IF NOT EXISTS events (
37
+ seq INTEGER PRIMARY KEY AUTOINCREMENT, execution_id TEXT NOT NULL,
38
+ session_id TEXT NOT NULL, value TEXT NOT NULL
39
+ );
40
+ CREATE INDEX IF NOT EXISTS event_sessions ON events(session_id,seq);
41
+ CREATE TABLE IF NOT EXISTS interactions (
42
+ id TEXT PRIMARY KEY, execution_id TEXT NOT NULL,
43
+ request TEXT NOT NULL, answer TEXT
44
+ );
45
+ CREATE TABLE IF NOT EXISTS controls (
46
+ id INTEGER PRIMARY KEY AUTOINCREMENT, execution_id TEXT NOT NULL,
47
+ operation TEXT NOT NULL, payload TEXT NOT NULL, state TEXT NOT NULL
48
+ );
49
+ """
50
+ )
51
+ os.chmod(self.path, 0o600)
52
+
53
+ @contextmanager
54
+ def connect(self) -> Iterator[sqlite3.Connection]:
55
+ db = sqlite3.connect(self.path, timeout=30)
56
+ db.row_factory = sqlite3.Row
57
+ try:
58
+ with db:
59
+ yield db
60
+ finally:
61
+ db.close()
62
+
63
+ def create_execution(self, value: dict[str, Any]) -> bool:
64
+ with self.connect() as db:
65
+ cursor = db.execute(
66
+ "INSERT OR IGNORE INTO executions VALUES(?,?,?,?)",
67
+ (
68
+ value["execution_id"],
69
+ value["session_id"],
70
+ value["created_at"],
71
+ json.dumps(value),
72
+ ),
73
+ )
74
+ return cursor.rowcount == 1
75
+
76
+ def execution(self, identity: str) -> dict[str, Any]:
77
+ with self.connect() as db:
78
+ row = db.execute("SELECT value FROM executions WHERE id=?", (identity,)).fetchone()
79
+ if row is None:
80
+ raise KeyError(f"unknown execution: {identity}")
81
+ return json.loads(row[0])
82
+
83
+ def latest(self, session_id: str) -> dict[str, Any] | None:
84
+ with self.connect() as db:
85
+ row = db.execute(
86
+ "SELECT value FROM executions WHERE session_id=? ORDER BY created DESC LIMIT 1",
87
+ (session_id,),
88
+ ).fetchone()
89
+ return json.loads(row[0]) if row else None
90
+
91
+ def update(self, identity: str, **fields: Any) -> dict[str, Any]:
92
+ with self.connect() as db:
93
+ db.execute("BEGIN IMMEDIATE")
94
+ row = db.execute("SELECT value FROM executions WHERE id=?", (identity,)).fetchone()
95
+ if row is None:
96
+ raise KeyError(identity)
97
+ value = {**json.loads(row[0]), **fields}
98
+ db.execute("UPDATE executions SET value=? WHERE id=?", (json.dumps(value), identity))
99
+ return value
100
+
101
+ def accept(
102
+ self, session_id: str, execution_id: str, identity: str, text: str
103
+ ) -> dict[str, Any]:
104
+ if not identity or not text.strip():
105
+ raise ValueError("message ID and text must be nonempty")
106
+ digest = hashlib.sha256(text.encode()).hexdigest()
107
+ with self.connect() as db:
108
+ db.execute("BEGIN IMMEDIATE")
109
+ row = db.execute(
110
+ "SELECT * FROM inputs WHERE session_id=? AND id=?", (session_id, identity)
111
+ ).fetchone()
112
+ if row is not None:
113
+ if row["digest"] != digest:
114
+ raise ValueError("message ID was already used with different text")
115
+ else:
116
+ db.execute(
117
+ "INSERT INTO inputs(session_id,id,execution_id,digest,text,status,created) "
118
+ "VALUES(?,?,?,?,?,'accepted',?)",
119
+ (session_id, identity, execution_id, digest, text, time.time()),
120
+ )
121
+ receipt = self.receipt(session_id, identity)
122
+ assert receipt is not None
123
+ return receipt
124
+
125
+ def receipt(self, session_id: str, identity: str) -> dict[str, Any] | None:
126
+ with self.connect() as db:
127
+ row = db.execute(
128
+ "SELECT id AS message_id,execution_id,status,error,native_uuid,created "
129
+ "FROM inputs WHERE session_id=? AND id=?",
130
+ (session_id, identity),
131
+ ).fetchone()
132
+ return dict(row) if row else None
133
+
134
+ def take_input(self, execution_id: str) -> dict[str, Any] | None:
135
+ with self.connect() as db:
136
+ db.execute("BEGIN IMMEDIATE")
137
+ row = db.execute(
138
+ "SELECT * FROM inputs WHERE execution_id=? AND status='accepted' "
139
+ "ORDER BY created LIMIT 1",
140
+ (execution_id,),
141
+ ).fetchone()
142
+ if row is not None:
143
+ db.execute(
144
+ "UPDATE inputs SET status='dispatching' WHERE session_id=? AND id=?",
145
+ (row["session_id"], row["id"]),
146
+ )
147
+ return dict(row) if row else None
148
+
149
+ def input_status(
150
+ self,
151
+ session_id: str,
152
+ identity: str,
153
+ status: str,
154
+ *,
155
+ error: str | None = None,
156
+ native_uuid: str | None = None,
157
+ ) -> None:
158
+ with self.connect() as db:
159
+ db.execute(
160
+ "UPDATE inputs SET status=?,error=?,native_uuid=COALESCE(?,native_uuid) "
161
+ "WHERE session_id=? AND id=?",
162
+ (status, error, native_uuid, session_id, identity),
163
+ )
164
+
165
+ def lose_dispatches(self, execution_id: str) -> None:
166
+ with self.connect() as db:
167
+ db.execute(
168
+ "UPDATE inputs SET status='uncertain',"
169
+ "error='worker exited before native admission acknowledgement' "
170
+ "WHERE execution_id=? AND status='dispatching'",
171
+ (execution_id,),
172
+ )
173
+
174
+ def reject_pending(self, execution_id: str, error: str) -> None:
175
+ with self.connect() as db:
176
+ db.execute(
177
+ "UPDATE inputs SET status='rejected',error=? "
178
+ "WHERE execution_id=? AND status='accepted'",
179
+ (error, execution_id),
180
+ )
181
+
182
+ def admit(self, execution_id: str, native_uuid: str) -> str | None:
183
+ with self.connect() as db:
184
+ db.execute("BEGIN IMMEDIATE")
185
+ row = db.execute(
186
+ "SELECT id FROM inputs "
187
+ "WHERE execution_id=? AND native_uuid=? AND status='dispatching'",
188
+ (execution_id, native_uuid),
189
+ ).fetchone()
190
+ if row is not None:
191
+ db.execute(
192
+ "UPDATE inputs SET status='delivered',error=NULL "
193
+ "WHERE execution_id=? AND native_uuid=?",
194
+ (execution_id, native_uuid),
195
+ )
196
+ return row[0] if row else None
197
+
198
+ def append(self, execution_id: str, session_id: str, value: dict[str, Any]) -> int:
199
+ with self.connect() as db:
200
+ cursor = db.execute(
201
+ "INSERT INTO events(execution_id,session_id,value) VALUES(?,?,?)",
202
+ (execution_id, session_id, json.dumps(value)),
203
+ )
204
+ assert cursor.lastrowid is not None
205
+ return cursor.lastrowid
206
+
207
+ def events(self, session_id: str, after: int = 0) -> list[dict[str, Any]]:
208
+ with self.connect() as db:
209
+ rows = db.execute(
210
+ "SELECT * FROM events WHERE session_id=? AND seq>? ORDER BY seq",
211
+ (session_id, after),
212
+ ).fetchall()
213
+ return [
214
+ {
215
+ "execution_id": row["execution_id"],
216
+ "cursor": {"execution_id": row["execution_id"], "offset": row["seq"]},
217
+ "event": json.loads(row["value"]),
218
+ }
219
+ for row in rows
220
+ ]
221
+
222
+ def request(self, execution_id: str, identity: str, value: dict[str, Any]) -> None:
223
+ with self.connect() as db:
224
+ db.execute(
225
+ "INSERT OR IGNORE INTO interactions VALUES(?,?,?,NULL)",
226
+ (identity, execution_id, json.dumps(value)),
227
+ )
228
+
229
+ def interactions(self, execution_id: str) -> list[dict[str, Any]]:
230
+ with self.connect() as db:
231
+ rows = db.execute(
232
+ "SELECT request FROM interactions WHERE execution_id=? AND answer IS NULL",
233
+ (execution_id,),
234
+ ).fetchall()
235
+ return [json.loads(row[0]) for row in rows]
236
+
237
+ def answer(self, execution_id: str, identity: str, value: dict[str, Any]) -> None:
238
+ encoded = json.dumps(value, sort_keys=True)
239
+ with self.connect() as db:
240
+ db.execute("BEGIN IMMEDIATE")
241
+ row = db.execute(
242
+ "SELECT answer FROM interactions WHERE id=? AND execution_id=?",
243
+ (identity, execution_id),
244
+ ).fetchone()
245
+ if row is None:
246
+ raise KeyError("unknown interaction")
247
+ if row[0] is not None and row[0] != encoded:
248
+ raise ValueError("interaction already has a different answer")
249
+ db.execute("UPDATE interactions SET answer=? WHERE id=?", (encoded, identity))
250
+
251
+ def response(self, identity: str) -> dict[str, Any] | None:
252
+ with self.connect() as db:
253
+ row = db.execute("SELECT answer FROM interactions WHERE id=?", (identity,)).fetchone()
254
+ return json.loads(row[0]) if row and row[0] else None
255
+
256
+ def control(self, execution_id: str, operation: str, payload: dict[str, Any]) -> int:
257
+ with self.connect() as db:
258
+ cursor = db.execute(
259
+ "INSERT INTO controls(execution_id,operation,payload,state) "
260
+ "VALUES(?,?,?,'pending')",
261
+ (execution_id, operation, json.dumps(payload)),
262
+ )
263
+ assert cursor.lastrowid is not None
264
+ return cursor.lastrowid
265
+
266
+ def take_controls(self, execution_id: str) -> list[dict[str, Any]]:
267
+ with self.connect() as db:
268
+ db.execute("BEGIN IMMEDIATE")
269
+ rows = db.execute(
270
+ "SELECT * FROM controls WHERE execution_id=? AND state='pending' ORDER BY id",
271
+ (execution_id,),
272
+ ).fetchall()
273
+ db.execute(
274
+ "UPDATE controls SET state='dispatched' WHERE execution_id=? AND state='pending'",
275
+ (execution_id,),
276
+ )
277
+ return [{**dict(row), "payload": json.loads(row["payload"])} for row in rows]
@@ -0,0 +1,290 @@
1
+ """One detached owner for one native Claude process and its durable event stream."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import signal
9
+ import sys
10
+ import time
11
+ import uuid
12
+ from dataclasses import asdict, is_dataclass
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import psutil
17
+ from claude_agent_sdk import (
18
+ TERMINAL_TASK_STATUSES,
19
+ AssistantMessage,
20
+ ClaudeAgentOptions,
21
+ ClaudeSDKClient,
22
+ PermissionResultAllow,
23
+ PermissionResultDeny,
24
+ ResultMessage,
25
+ TextBlock,
26
+ ToolPermissionContext,
27
+ UserMessage,
28
+ )
29
+ from filelock import FileLock
30
+
31
+ from agent_runtime.monitors import Monitors
32
+ from agent_runtime.store import Store
33
+
34
+ log = logging.getLogger(__name__)
35
+
36
+
37
+ class Worker:
38
+ def __init__(self, store: Store, execution_id: str) -> None:
39
+ self.store = store
40
+ self.execution_id = execution_id
41
+ self.execution = store.execution(execution_id)
42
+ self.session_id = self.execution["session_id"]
43
+ self.stopping = asyncio.Event()
44
+ self.active: dict[str, Any] | None = None
45
+ self.last_activity = time.monotonic()
46
+ self.background_tasks: set[str] = set()
47
+ self.presentation_started = False
48
+
49
+ def emit(self, kind: str, **fields: Any) -> None:
50
+ self.store.append(self.execution_id, self.session_id, {"type": kind, **fields})
51
+
52
+ async def permission(
53
+ self, name: str, value: dict[str, Any], context: ToolPermissionContext
54
+ ) -> Any:
55
+ identity = "Q" + uuid.uuid4().hex[:12]
56
+ self.store.request(
57
+ self.execution_id,
58
+ identity,
59
+ {
60
+ "id": identity,
61
+ "provider": "claude_code",
62
+ "method": "agent/question"
63
+ if name == "AskUserQuestion"
64
+ else "agent/requestApproval",
65
+ "params": {
66
+ "tool": name,
67
+ "input": value,
68
+ "title": context.title,
69
+ "description": context.description,
70
+ "reason": context.decision_reason,
71
+ "tool_use_id": context.tool_use_id,
72
+ "agent_id": context.agent_id,
73
+ },
74
+ },
75
+ )
76
+ self.emit("interaction.requested", id=identity)
77
+ while not self.stopping.is_set():
78
+ answer = self.store.response(identity)
79
+ if answer is not None:
80
+ if name == "AskUserQuestion":
81
+ answers = answer.get("answers")
82
+ if not isinstance(answers, dict) or not all(
83
+ isinstance(k, str) and isinstance(v, str) for k, v in answers.items()
84
+ ):
85
+ return PermissionResultDeny(
86
+ message="invalid question response: expected a string-to-string mapping"
87
+ )
88
+ return PermissionResultAllow(updated_input={**value, "answers": answers})
89
+ if answer.get("decision") == "allow":
90
+ return PermissionResultAllow(updated_input=value)
91
+ return PermissionResultDeny(message="The user denied this operation.")
92
+ await asyncio.sleep(0.1)
93
+ return PermissionResultDeny(message="Worker is stopping.", interrupt=True)
94
+
95
+ async def receive(self, client: ClaudeSDKClient) -> None:
96
+ async for message in client.receive_messages():
97
+ self.last_activity = time.monotonic()
98
+ raw = asdict(message) if is_dataclass(message) else {"description": repr(message)}
99
+ self.emit("claude.message", message_class=type(message).__name__, message=raw)
100
+ if isinstance(message, UserMessage) and message.uuid:
101
+ if admitted := self.store.admit(self.execution_id, message.uuid):
102
+ self.emit("input.delivered", message_id=admitted, native_uuid=message.uuid)
103
+ elif isinstance(message, AssistantMessage) and message.parent_tool_use_id is None:
104
+ if not self.presentation_started:
105
+ self.emit("turn.started", origin="native")
106
+ self.presentation_started = True
107
+ text = "\n".join(
108
+ block.text for block in message.content if isinstance(block, TextBlock)
109
+ )
110
+ if text:
111
+ self.emit("agent.message", text=text, native_uuid=message.uuid)
112
+ elif isinstance(message, ResultMessage):
113
+ if message.session_id != self.session_id:
114
+ raise RuntimeError("native Claude returned a different session ID")
115
+ human = message.origin is None or message.origin.get("kind") == "human"
116
+ active = self.active if human else None
117
+ status = "failed" if message.is_error else "completed"
118
+ if message.terminal_reason in {"aborted_streaming", "aborted_tools"}:
119
+ status = "interrupted"
120
+ self.emit(
121
+ "agent.turn.completed",
122
+ status=status,
123
+ origin=message.origin,
124
+ terminal_reason=message.terminal_reason,
125
+ usage=message.usage,
126
+ )
127
+ self.emit(
128
+ "work.settled",
129
+ status=status,
130
+ message=message.result or "",
131
+ message_ids=[active["id"]] if active else [],
132
+ origin=message.origin,
133
+ errors=message.errors,
134
+ error="; ".join(message.errors or []) or None,
135
+ last_turn_status=status,
136
+ )
137
+ self.presentation_started = False
138
+ if active:
139
+ receipt = self.store.receipt(self.session_id, active["id"])
140
+ if receipt and receipt["status"] == "dispatching":
141
+ # A result does not identify which input UUID it accepted.
142
+ # Preserve uncertainty if the expected native echo was lost.
143
+ self.store.input_status(
144
+ self.session_id,
145
+ active["id"],
146
+ "uncertain",
147
+ error="native result arrived without input UUID acknowledgement",
148
+ )
149
+ self.active = None
150
+ else:
151
+ data = getattr(message, "data", {})
152
+ subtype = getattr(message, "subtype", "")
153
+ task_id = data.get("task_id") if isinstance(data, dict) else None
154
+ if task_id and subtype == "task_started":
155
+ self.background_tasks.add(task_id)
156
+ elif (task_id and subtype == "task_notification") or (
157
+ task_id
158
+ and subtype == "task_updated"
159
+ and data.get("patch", {}).get("status") in TERMINAL_TASK_STATUSES
160
+ ):
161
+ self.background_tasks.discard(task_id)
162
+ if not self.stopping.is_set():
163
+ raise RuntimeError("native Claude event stream closed")
164
+
165
+ async def run(self) -> None:
166
+ policy = self.execution["configuration"]
167
+ options = ClaudeAgentOptions(
168
+ cwd=policy["cwd"],
169
+ cli_path=policy["claude"],
170
+ permission_mode=policy["permission_mode"],
171
+ allowed_tools=policy["allowed_tools"],
172
+ model=policy["model"],
173
+ setting_sources=["user", "project", "local"],
174
+ system_prompt={
175
+ "type": "preset",
176
+ "preset": "claude_code",
177
+ "append": policy["instructions"] or "",
178
+ },
179
+ can_use_tool=self.permission,
180
+ stderr=lambda line: log.debug("claude: %s", line),
181
+ extra_args={"replay-user-messages": None},
182
+ session_id=self.session_id
183
+ if not self.execution["resume"] or self.execution["fork"]
184
+ else None,
185
+ resume=self.execution["resume"],
186
+ fork_session=self.execution["fork"],
187
+ )
188
+ for signum in (signal.SIGTERM, signal.SIGINT):
189
+ asyncio.get_running_loop().add_signal_handler(signum, self.stopping.set)
190
+ client = ClaudeSDKClient(options)
191
+ reader: asyncio.Task[None] | None = None
192
+ result: dict[str, Any] = {"status": "stopped"}
193
+ try:
194
+ await client.connect()
195
+ self.store.update(self.execution_id, state="running", ready_at=time.time())
196
+ self.emit("worker.ready", provider="claude_code", session_id=self.session_id)
197
+ reader = asyncio.create_task(self.receive(client))
198
+ while not self.stopping.is_set():
199
+ if reader.done():
200
+ await reader
201
+ break
202
+ for control in self.store.take_controls(self.execution_id):
203
+ match control["operation"]:
204
+ case "stop":
205
+ self.stopping.set()
206
+ case "interrupt":
207
+ await client.interrupt()
208
+ case "set_model":
209
+ await client.set_model(control["payload"]["model"])
210
+ case _:
211
+ self.emit(
212
+ "control.failed", id=control["id"], error="unsupported operation"
213
+ )
214
+ if self.stopping.is_set():
215
+ break
216
+ if self.active is None:
217
+ self.active = self.store.take_input(self.execution_id)
218
+ if self.active:
219
+ native_uuid = str(uuid.uuid4())
220
+ self.store.input_status(
221
+ self.session_id,
222
+ self.active["id"],
223
+ "dispatching",
224
+ native_uuid=native_uuid,
225
+ )
226
+ text = self.active["text"]
227
+ input_id = self.active["id"]
228
+
229
+ async def prompt(native_uuid=native_uuid, text=text):
230
+ yield {
231
+ "type": "user",
232
+ "uuid": native_uuid,
233
+ "session_id": self.session_id,
234
+ "origin": {"kind": "human"},
235
+ "message": {"role": "user", "content": text},
236
+ }
237
+
238
+ self.emit("turn.started", message_id=input_id, native_uuid=native_uuid)
239
+ self.presentation_started = True
240
+ await client.query(prompt(), session_id=self.session_id)
241
+ self.last_activity = time.monotonic()
242
+ timeout = policy["idle_timeout_seconds"]
243
+ if (
244
+ timeout
245
+ and self.active is None
246
+ and not self.background_tasks
247
+ and time.monotonic() - self.last_activity >= timeout
248
+ and not any(
249
+ monitor["status"] in {"starting", "running"}
250
+ for monitor in Monitors().list(self.session_id)
251
+ )
252
+ ):
253
+ result = {"status": "completed", "reason": "idle_timeout"}
254
+ break
255
+ await asyncio.sleep(0.1)
256
+ except Exception as error:
257
+ log.exception("Claude worker failed")
258
+ result = {"status": "failed", "error": str(error)}
259
+ finally:
260
+ self.stopping.set()
261
+ try:
262
+ await client.disconnect()
263
+ except Exception:
264
+ log.exception("Claude disconnect failed")
265
+ if reader is not None:
266
+ reader.cancel()
267
+ await asyncio.gather(reader, return_exceptions=True)
268
+ self.store.lose_dispatches(self.execution_id)
269
+ self.store.reject_pending(self.execution_id, "worker stopped before dispatch")
270
+ self.store.update(
271
+ self.execution_id, state="exited", result=result, exited_at=time.time()
272
+ )
273
+ self.emit("worker.exited", **result)
274
+
275
+
276
+ def main() -> None:
277
+ os.umask(0o077)
278
+ logging.basicConfig(
279
+ level=logging.DEBUG, format="%(asctime)s %(levelname)s %(name)s %(message)s"
280
+ )
281
+ store, identity = Store(Path(sys.argv[1])), sys.argv[2]
282
+ value = store.execution(identity)
283
+ with FileLock(str(store.root / f"lease-{value['session_id']}.lock"), timeout=0):
284
+ process = psutil.Process()
285
+ store.update(identity, pid=process.pid, process_created_at=process.create_time())
286
+ asyncio.run(Worker(store, identity).run())
287
+
288
+
289
+ if __name__ == "__main__":
290
+ main()
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.5
2
+ Name: agent-runtime-py
3
+ Version: 0.1.0
4
+ Summary: Reconnectable native agent workers with durable input receipts and events
5
+ Project-URL: Repository, https://github.com/nimashoghi/agent-runtime
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: claude-agent-sdk==0.2.159
10
+ Requires-Dist: filelock<4,>=3.16
11
+ Requires-Dist: platformdirs<5,>=4
12
+ Requires-Dist: psutil<8,>=7
13
+ Requires-Dist: typed-agent-hooks<0.3,>=0.2.2
14
+ Description-Content-Type: text/markdown
15
+
16
+ # Agent runtime
17
+
18
+ `agent-runtime-py` provides detached Claude Code workers that applications can reconnect to after restarting. Import it as `agent_runtime`. It uses the official Claude Agent SDK and your installed Claude Code executable, loading native user and project settings, hooks, skills, and MCP servers.
19
+
20
+ This repository is under active development. The current implementation supports macOS and Linux and has been exercised with Claude Code 2.1.281 and Claude Agent SDK 0.2.159. Windows is not supported.
21
+
22
+ ```python
23
+ from pathlib import Path
24
+ from agent_runtime import ClaudeRuntime, LaunchOptions
25
+
26
+ runtime = ClaudeRuntime(Path.home() / ".local/state/example/claude")
27
+ execution = await runtime.start(
28
+ "Inspect this project's tests.",
29
+ message_id="request-123",
30
+ launch_id="conversation-456:request-123",
31
+ options=LaunchOptions(cwd=Path.cwd()),
32
+ )
33
+ ```
34
+
35
+ The default permission mode is Claude's ordinary permission behavior. Pending approvals and questions are persisted in `runtime.store.interactions(execution_id)` and remain pending until the application supplies an explicit answer. Applications must present these requests to the user; this library does not auto-approve them.
36
+
37
+ ## Lifetime and delivery
38
+
39
+ - Each execution is a detached Python worker owning one native Claude SDK connection. Destroying the application client does not stop the worker.
40
+ - A native session can have several executions after explicit stop and resume. A process lease prevents two managed workers from resuming it simultaneously.
41
+ - Message IDs are idempotent within a session. Reusing an ID with different text is an error. A launch ID also fixes the initial prompt and launch configuration.
42
+ - `accepted` means privately journaled, `dispatching` means sent or being sent, and `delivered` requires Claude to echo the exact input UUID. A crash before that acknowledgement produces `uncertain`; the runtime never silently replays that input.
43
+ - Process identity includes PID and process creation time. Restarting a dead worker requires an explicit resume request. A process restart does not imply resubmitting previously delivered work.
44
+ - Events have durable increasing cursors across executions. Native SDK messages are retained as `claude.message`; presentation events include `agent.message`, `agent.turn.completed`, and `work.settled`.
45
+ - A normal turn result leaves the worker available for more requests. The idle timeout applies only when there is no active request or tracked background task or running monitor. Set it to zero to keep the worker open until explicitly stopped.
46
+ - Private SQLite state and logs contain conversation content. Environment variables are passed to the child without being serialized into launch configuration.
47
+
48
+ `runtime.stop(execution_id)` requests shutdown. `runtime.attach(execution_id)` returns an interactive resume command only after the managed worker has stopped, preventing concurrent native ownership. It must be launched from the recorded working directory.
49
+
50
+ ## Monitors and local peer messages
51
+
52
+ Install `agent-runtime-hooks` using a typed-agent-hooks `Collection` for both providers. The hooks register native process identities and current permission modes. Claude hooks start a private delivery child whose inherited native inbox token remains in memory; it is never written to the registry or used for messages to another session.
53
+
54
+ `agent_runtime.operations` supplies `monitor_start`, `monitor_list`, `monitor_get`, `monitor_stop`, `monitor_events`, `agents`, and `send`. Codex operations delegate to the installed `codexr` CLI. Claude monitors run a detached foreground Bash command and retain stdout observations in SQLite; stderr goes to a private log. A 1 MiB output limit stops an overly verbose monitor. Delivery batches retain each monitor's launch permission class. Native Claude receives these as peer context and applies its own admission policy, including holding messages if permission modes require approval. `transport-written` confirms only a socket write, not admission or model processing. Interrupted writes remain uncertain and are not replayed automatically.
55
+
56
+ A normal Claude monitor stops when its owning native process exits or its deadline expires. A persistent monitor has no deadline and continues collecting output after CLI exit; resuming the exact native session starts a new delivery child for pending observations. This does not keep a plain `claude -p` process open or restart processes after a host reboot. Interactive sessions and managed SDK workers receive observations between turns. A managed worker's idle timeout does not end a running monitor.
57
+
58
+ Cross-session sends identify their source and never use the destination's own-event token. Unknown source permission modes fail closed. Codex receipts preserve its native delivered/queued meanings; Claude socket receipts are transport-only. Native policy or a stopped process can prevent delivery.
59
+
60
+ Goals use Claude's native `/goal`, including its evaluator, retry limits and resume behavior. There is no second goal store or emulated Codex goal API. An objective can survive native resume while usage baselines reset; unrecoverable errors can clear it. See [Claude goals](https://code.claude.com/docs/en/goal).
61
+
62
+ ## Development
63
+
64
+ Run `uv run pytest -q`. The integration tests use the real Claude Agent SDK and detached worker processes against a deterministic fake native CLI. They cover application reconnection, stop/resume, duplicate admission, lost acknowledgement, explicit permission decisions, actual shell monitor failure, process-tree shutdown, deadlines, retained observations across native-owner exit/resume, and peer permission labels. They require no model account or API credentials.
65
+
66
+ Release publishing uses GitHub Actions trusted publishing. Configure a pending PyPI publisher with project `agent-runtime-py`, owner `nimashoghi`, repository `agent-runtime`, workflow `release.yml`, and environment `pypi`. Publishing a GitHub release whose tag matches `v<package-version>` builds and publishes the package.
@@ -0,0 +1,15 @@
1
+ agent_runtime/__init__.py,sha256=s8nyCGrLbrqQiLh_JDfD9hdyEv0TEZ_EPYS6TF8xIDo,177
2
+ agent_runtime/bridge.py,sha256=YBdzf6-qgbnAToxkY-qEPVXB-STQY7CUWlQn7jRyHoM,3224
3
+ agent_runtime/claude.py,sha256=0-NYA_fy6H2KLp-ZStbGGV3ZwK3bTLkQcnA64U_-t5E,9903
4
+ agent_runtime/hooks.py,sha256=rR6RbC6W31sYc04n0W16zurrpSG8FacyVpA_xd52w58,2461
5
+ agent_runtime/monitor_worker.py,sha256=CfTs6ultwMPneoNdhrwCBWUarAKl9je6Z4ahBcOg0Cg,5771
6
+ agent_runtime/monitors.py,sha256=shCIV8q5n0fay63FM6D5BGjczDDaz29ltYFHOXfn9Co,8752
7
+ agent_runtime/operations.py,sha256=FCWL3oj0aoByUF7NzU1lFf_-oyGkDN4pKEmOLI14qoI,4126
8
+ agent_runtime/sessions.py,sha256=bP82Lusb2XyerIx4Pk1N4dDJAbEER74rsWG-0HS-Zko,8131
9
+ agent_runtime/store.py,sha256=Ca0kWe4mx4zO-4-t1cBJhqtYLcV_dejhegp9_vVGN7w,11533
10
+ agent_runtime/worker.py,sha256=b0SJ4ewYuxkX5-QNn58jznGb_3OZIxoZ6u9_JAulUtQ,12816
11
+ agent_runtime_py-0.1.0.dist-info/METADATA,sha256=P_r8Gvk8mKWZSMBLayzZExyJFDPpRkzyY__8bt2DeJE,6553
12
+ agent_runtime_py-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
13
+ agent_runtime_py-0.1.0.dist-info/entry_points.txt,sha256=xM6Z54Wl_Ry4eQ835rdGJ0bz92lMXMJW8U1sJd-gkL4,65
14
+ agent_runtime_py-0.1.0.dist-info/licenses/LICENSE,sha256=pLYhRdUXNvcg7DNLI4Ce74hsHeGEOwDXIRjVyI0MxnI,1068
15
+ agent_runtime_py-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agent-runtime-hooks = agent_runtime.hooks:main