agent-runtime-py 0.1.1__tar.gz → 0.2.1__tar.gz

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.
Files changed (22) hide show
  1. agent_runtime_py-0.2.1/CHANGELOG.md +14 -0
  2. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/PKG-INFO +6 -5
  3. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/README.md +2 -2
  4. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/pyproject.toml +6 -6
  5. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/src/agent_runtime/claude.py +6 -0
  6. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/src/agent_runtime/hooks.py +10 -25
  7. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/src/agent_runtime/monitor_worker.py +19 -7
  8. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/src/agent_runtime/sessions.py +12 -1
  9. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/src/agent_runtime/worker.py +22 -1
  10. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/tests/test_claude_runtime.py +81 -2
  11. agent_runtime_py-0.2.1/tests/test_hooks.py +179 -0
  12. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/tests/test_monitors.py +40 -5
  13. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/uv.lock +16 -82
  14. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/.github/workflows/ci.yml +0 -0
  15. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/.github/workflows/release.yml +0 -0
  16. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/.gitignore +0 -0
  17. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/LICENSE +0 -0
  18. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/src/agent_runtime/__init__.py +0 -0
  19. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/src/agent_runtime/bridge.py +0 -0
  20. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/src/agent_runtime/monitors.py +0 -0
  21. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/src/agent_runtime/operations.py +0 -0
  22. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/src/agent_runtime/store.py +0 -0
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## 0.2.1
4
+
5
+ - Fix: a Claude input that native Claude folded into a turn started by something else (a peer message, a background task) never settled, because that turn's result carries the other origin. The worker then took no further input and never exited on idle. A result now settles the active input when the input's echo arrived before it.
6
+ - Fix: a turn that native Claude runs for another origin (a peer message) counts as work: the worker reports `turn.started` when that message arrives, not at the first assistant text, and does not exit on idle while the turn runs.
7
+ - Fix: the Claude worker starts with `-P`, so the session's working folder is not on its import path.
8
+
9
+ ## 0.2.0
10
+
11
+ - Breaking: requires typed-agent-hooks 0.3. Run the hooks with `agent_runtime.hooks.app.main()` and the host name (`codex` or `claude`) as the only argument; the host hook file sets the timeout. The `agent-runtime-hooks` console script is removed.
12
+ - Breaking: the Claude Agent SDK moves to the `claude` extra. `ClaudeRuntime` needs `agent-runtime-py[claude]`.
13
+ - Fix: a monitor worker whose command exceeded the output limit never exited and ignored SIGTERM.
14
+ - Fix: a Claude SessionStart, which carries no permission mode, kept the mode empty; it now keeps the mode that the same live Claude process reported.
@@ -1,21 +1,22 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agent-runtime-py
3
- Version: 0.1.1
3
+ Version: 0.2.1
4
4
  Summary: Reconnectable native agent workers with durable input receipts and events
5
5
  Project-URL: Repository, https://github.com/nimashoghi/agent-runtime
6
6
  License-Expression: MIT
7
7
  License-File: LICENSE
8
8
  Requires-Python: >=3.11
9
- Requires-Dist: claude-agent-sdk==0.2.159
10
9
  Requires-Dist: filelock<4,>=3.16
11
10
  Requires-Dist: platformdirs<5,>=4
12
11
  Requires-Dist: psutil<8,>=7
13
- Requires-Dist: typed-agent-hooks
12
+ Requires-Dist: typed-agent-hooks<0.4,>=0.3
13
+ Provides-Extra: claude
14
+ Requires-Dist: claude-agent-sdk==0.2.159; extra == 'claude'
14
15
  Description-Content-Type: text/markdown
15
16
 
16
17
  # Agent runtime
17
18
 
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
+ `agent-runtime-py` provides detached Claude Code workers that applications can reconnect to after restarting. Import it as `agent_runtime`. The workers need the `claude` extra (`agent-runtime-py[claude]`), which installs the Claude Agent SDK; the hooks, monitors and peer messages do not. 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
 
20
21
  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
 
@@ -49,7 +50,7 @@ The default permission mode is Claude's ordinary permission behavior. Pending ap
49
50
 
50
51
  ## Monitors and local peer messages
51
52
 
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
+ Run the hooks from an executable PEP 723 script with an exact `agent-runtime-py` pin and a `[tool.uv] exclude-newer` date, whose body is `from agent_runtime.hooks import app` and `if __name__ == "__main__": app.main()`. Register that script for SessionStart, UserPromptSubmit and PreToolUse in each host's hook file, with the host name (`codex` or `claude`) as its only argument and a timeout of 10 seconds. End each command with `|| true`: uv exits nonzero when it cannot start the script (2, for example, when the package index is unreachable), and both hosts read exit 2 on UserPromptSubmit and PreToolUse as a block. 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
 
54
55
  `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
 
@@ -1,6 +1,6 @@
1
1
  # Agent runtime
2
2
 
3
- `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.
3
+ `agent-runtime-py` provides detached Claude Code workers that applications can reconnect to after restarting. Import it as `agent_runtime`. The workers need the `claude` extra (`agent-runtime-py[claude]`), which installs the Claude Agent SDK; the hooks, monitors and peer messages do not. It uses the official Claude Agent SDK and your installed Claude Code executable, loading native user and project settings, hooks, skills, and MCP servers.
4
4
 
5
5
  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.
6
6
 
@@ -34,7 +34,7 @@ The default permission mode is Claude's ordinary permission behavior. Pending ap
34
34
 
35
35
  ## Monitors and local peer messages
36
36
 
37
- 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.
37
+ Run the hooks from an executable PEP 723 script with an exact `agent-runtime-py` pin and a `[tool.uv] exclude-newer` date, whose body is `from agent_runtime.hooks import app` and `if __name__ == "__main__": app.main()`. Register that script for SessionStart, UserPromptSubmit and PreToolUse in each host's hook file, with the host name (`codex` or `claude`) as its only argument and a timeout of 10 seconds. End each command with `|| true`: uv exits nonzero when it cannot start the script (2, for example, when the package index is unreachable), and both hosts read exit 2 on UserPromptSubmit and PreToolUse as a block. 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.
38
38
 
39
39
  `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.
40
40
 
@@ -4,22 +4,22 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agent-runtime-py"
7
- version = "0.1.1"
7
+ version = "0.2.1"
8
8
  description = "Reconnectable native agent workers with durable input receipts and events"
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  license-files = ["LICENSE"]
12
12
  requires-python = ">=3.11"
13
13
  dependencies = [
14
- "claude-agent-sdk==0.2.159",
15
14
  "filelock>=3.16,<4",
16
15
  "platformdirs>=4,<5",
17
16
  "psutil>=7,<8",
18
- "typed-agent-hooks",
17
+ "typed-agent-hooks>=0.3,<0.4",
19
18
  ]
20
19
 
21
- [project.scripts]
22
- agent-runtime-hooks = "agent_runtime.hooks:main"
20
+ [project.optional-dependencies]
21
+ # Only the detached Claude worker (`ClaudeRuntime`) uses the SDK; hooks and monitors do not.
22
+ claude = ["claude-agent-sdk==0.2.159"]
23
23
 
24
24
  [project.urls]
25
25
  Repository = "https://github.com/nimashoghi/agent-runtime"
@@ -28,7 +28,7 @@ Repository = "https://github.com/nimashoghi/agent-runtime"
28
28
  packages = ["src/agent_runtime"]
29
29
 
30
30
  [dependency-groups]
31
- dev = ["pytest>=8.4,<9", "pytest-asyncio>=1.1,<2", "ruff>=0.12,<1", "basedpyright>=1.31,<2", "build>=1.3,<2"]
31
+ dev = ["agent-runtime-py[claude]", "pytest>=8.4,<9", "pytest-asyncio>=1.1,<2", "ruff>=0.12,<1", "basedpyright>=1.31,<2", "build>=1.3,<2"]
32
32
 
33
33
  [tool.pytest.ini_options]
34
34
  asyncio_mode = "auto"
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import hashlib
7
+ import importlib.util
7
8
  import json
8
9
  import math
9
10
  import os
@@ -77,6 +78,8 @@ class ClaudeRuntime:
77
78
  """The client owns no worker lifetime and can reconnect after a service restart."""
78
79
 
79
80
  def __init__(self, directory: Path) -> None:
81
+ if importlib.util.find_spec("claude_agent_sdk") is None:
82
+ raise RuntimeError("ClaudeRuntime needs the claude extra: agent-runtime-py[claude]")
80
83
  self.store = Store(directory)
81
84
 
82
85
  def inspect(self, execution_id: str) -> dict[str, Any]:
@@ -174,6 +177,9 @@ class ClaudeRuntime:
174
177
  child = subprocess.Popen(
175
178
  [
176
179
  sys.executable,
180
+ # The worker's cwd is the session's folder, which the agent
181
+ # writes; it must not be on the worker's import path.
182
+ "-P",
177
183
  "-m",
178
184
  "agent_runtime.worker",
179
185
  str(self.store.root),
@@ -14,15 +14,18 @@ from agent_runtime.sessions import Registry, own_claude_registration
14
14
  app = shared.HookApp(name="agent-runtime")
15
15
 
16
16
 
17
- def register(event) -> None:
17
+ def register(
18
+ event: shared.events.SessionStarted
19
+ | shared.events.PromptSubmitted
20
+ | shared.events.ToolCallProposed,
21
+ ) -> None:
18
22
  context = event.context
19
23
  if context.agent_id:
20
24
  return
21
25
  registry = Registry()
22
- provider = context.provider.value
23
- if provider == "codex":
26
+ if context.host == "codex":
24
27
  registry.update(
25
- provider, context.session_id, cwd=context.cwd, permission_mode=context.permission_mode
28
+ "codex", context.session_id, cwd=context.cwd, permission_mode=context.permission_mode
26
29
  )
27
30
  return
28
31
  with FileLock(str(registry.store.root / f"bridge-start-{context.session_id}.lock"), timeout=5):
@@ -59,24 +62,6 @@ def register(event) -> None:
59
62
  )
60
63
 
61
64
 
62
- @app.on(shared.events.SessionStarted, timeout=10)
63
- def session_started(event: shared.events.SessionStarted) -> None:
64
- register(event)
65
-
66
-
67
- @app.on(shared.events.PromptSubmitted, timeout=10)
68
- def prompt_submitted(event: shared.events.PromptSubmitted) -> None:
69
- register(event)
70
-
71
-
72
- @app.on(shared.events.ToolCallProposed, timeout=10)
73
- def tool_call_proposed(event: shared.events.ToolCallProposed) -> None:
74
- register(event)
75
-
76
-
77
- def main() -> None:
78
- app.main()
79
-
80
-
81
- if __name__ == "__main__":
82
- main()
65
+ app.on(shared.events.SessionStarted)(register)
66
+ app.on(shared.events.PromptSubmitted)(register)
67
+ app.on(shared.events.ToolCallProposed)(register)
@@ -14,6 +14,18 @@ from agent_runtime.claude import process_alive
14
14
  from agent_runtime.monitors import Monitors
15
15
 
16
16
 
17
+ async def exited(child: asyncio.subprocess.Process, timeout: float) -> bool:
18
+ """Wait until the child exits, but not for its pipes to close.
19
+
20
+ `Process.wait()` called before the child exits returns only after stdout
21
+ and stderr close, and a pipe that no reader drains never closes.
22
+ """
23
+ deadline = time.monotonic() + timeout
24
+ while child.returncode is None and time.monotonic() < deadline:
25
+ await asyncio.sleep(0.05)
26
+ return child.returncode is not None
27
+
28
+
17
29
  async def run(directory: Path, identity: str) -> None:
18
30
  monitors = Monitors(directory)
19
31
  value = monitors.get(identity)
@@ -93,17 +105,17 @@ async def run(directory: Path, identity: str) -> None:
93
105
  if stop.is_set():
94
106
  if process_alive({"pid": child.pid, "process_created_at": child_created}):
95
107
  os.killpg(child.pid, signal.SIGTERM)
96
- try:
97
- await asyncio.wait_for(child.wait(), timeout=5)
98
- except TimeoutError:
99
- if process_alive({"pid": child.pid, "process_created_at": child_created}):
100
- os.killpg(child.pid, signal.SIGKILL)
108
+ if not await exited(child, 5) and process_alive(
109
+ {"pid": child.pid, "process_created_at": child_created}
110
+ ):
111
+ os.killpg(child.pid, signal.SIGKILL)
101
112
  break
102
113
  for reader in readers:
103
114
  if reader.done() and (error := reader.exception()) is not None:
104
115
  raise error
105
116
  await asyncio.sleep(0.1)
106
- await child.wait()
117
+ # Stop can win the race with a normal exit; record the exit code then too.
118
+ await exited(child, 5)
107
119
  try:
108
120
  await asyncio.wait_for(asyncio.gather(*readers), timeout=5)
109
121
  except TimeoutError as error:
@@ -134,7 +146,7 @@ async def run(directory: Path, identity: str) -> None:
134
146
  if child is not None and child.returncode is None:
135
147
  if process_alive({"pid": child.pid, "process_created_at": child_created}):
136
148
  os.killpg(child.pid, signal.SIGKILL)
137
- await child.wait()
149
+ await exited(child, 5)
138
150
  for reader in readers:
139
151
  reader.cancel()
140
152
  await asyncio.gather(*readers, return_exceptions=True)
@@ -114,7 +114,18 @@ def own_claude_registration(
114
114
  process = psutil.Process(pid)
115
115
  if process.uids().real != os.getuid():
116
116
  raise RuntimeError("native Claude process belongs to a different user")
117
- return (registry or Registry()).update(
117
+ registry = registry or Registry()
118
+ # Claude's SessionStart payload has no permission mode. Keep the mode that
119
+ # this same live process reported; a resumed session in a new process has
120
+ # none until a prompt or tool call reports one.
121
+ if permission_mode is None:
122
+ try:
123
+ previous = registry.get("claude_code", session_id)
124
+ except KeyError:
125
+ previous = {}
126
+ if previous.get("pid") == pid and process_alive(previous):
127
+ permission_mode = previous.get("permission_mode")
128
+ return registry.update(
118
129
  "claude_code",
119
130
  session_id,
120
131
  cwd=cwd,
@@ -100,6 +100,17 @@ class Worker:
100
100
  if isinstance(message, UserMessage) and message.uuid:
101
101
  if admitted := self.store.admit(self.execution_id, message.uuid):
102
102
  self.emit("input.delivered", message_id=admitted, native_uuid=message.uuid)
103
+ if self.active and self.active["id"] == admitted:
104
+ self.active["acknowledged"] = True
105
+ elif (
106
+ (message.origin or {}).get("kind", "human") != "human"
107
+ and message.parent_tool_use_id is None
108
+ and not self.presentation_started
109
+ ):
110
+ # A message from another origin (a peer session, a background task)
111
+ # starts a turn that no input of this runtime asked for.
112
+ self.emit("turn.started", origin="native")
113
+ self.presentation_started = True
103
114
  elif isinstance(message, AssistantMessage) and message.parent_tool_use_id is None:
104
115
  if not self.presentation_started:
105
116
  self.emit("turn.started", origin="native")
@@ -113,7 +124,15 @@ class Worker:
113
124
  if message.session_id != self.session_id:
114
125
  raise RuntimeError("native Claude returned a different session ID")
115
126
  human = message.origin is None or message.origin.get("kind") == "human"
116
- active = self.active if human else None
127
+ # Native Claude can fold an input into a turn that something else started
128
+ # (a peer message, a background task); that turn's result carries the other
129
+ # origin. The turn that follows the input's echo is the one that consumed it.
130
+ # A non-human result before the echo belongs to an earlier turn.
131
+ active = (
132
+ self.active
133
+ if self.active and (human or self.active.get("acknowledged"))
134
+ else None
135
+ )
117
136
  status = "failed" if message.is_error else "completed"
118
137
  if message.terminal_reason in {"aborted_streaming", "aborted_tools"}:
119
138
  status = "interrupted"
@@ -243,6 +262,8 @@ class Worker:
243
262
  if (
244
263
  timeout
245
264
  and self.active is None
265
+ # A turn that native Claude runs for another origin is work too.
266
+ and not self.presentation_started
246
267
  and not self.background_tasks
247
268
  and time.monotonic() - self.last_activity >= timeout
248
269
  and not any(
@@ -24,10 +24,11 @@ if "--version" in sys.argv:
24
24
  def emit(value):
25
25
  print(json.dumps(value), flush=True)
26
26
  session = next(a.split("=", 1)[1] for a in sys.argv if a.startswith(("--session-id=", "--resume=")))
27
- def result(text):
27
+ PEER = {"kind":"peer","from":"unknown"}
28
+ def result(text, origin={"kind":"human"}):
28
29
  emit({"type":"result", "subtype":"success", "is_error":False,
29
30
  "duration_ms":1,"duration_api_ms":1,"num_turns":1,"session_id":session,
30
- "result":text,"origin":{"kind":"human"},"usage":{}})
31
+ "result":text,"origin":origin,"usage":{}})
31
32
  pending = None
32
33
  for line in sys.stdin:
33
34
  message = json.loads(line)
@@ -40,7 +41,25 @@ for line in sys.stdin:
40
41
  log.write(json.dumps({"text": text, "uuid":message["uuid"], "session": session}) + "\n")
41
42
  if text == "crash-before-echo":
42
43
  raise SystemExit(23)
44
+ if text == "folded-into-peer-turn":
45
+ # Native Claude, observed 2026-11-10: a peer message's turn ends, a second peer
46
+ # message starts a turn, the queued human input joins that turn, and the turn's
47
+ # result carries the peer origin.
48
+ result("first peer turn", PEER)
49
+ emit({"type":"user","uuid":"peer-message","message":{"role":"user","content":"peer"},"origin":PEER})
50
+ emit({"type":"user","uuid":message["uuid"],"message":{"role":"user","content":text},"origin":{"kind":"human"}})
51
+ result("answer to both", PEER)
52
+ continue
43
53
  emit({"type":"user","uuid":message["uuid"],"message":{"role":"user","content":text},"origin":{"kind":"human"}})
54
+ if text == "then-a-peer-turn":
55
+ # The input's own turn ends; a peer message then starts a turn that works
56
+ # in silence for longer than the idle timeout.
57
+ import time
58
+ result("echo: " + text)
59
+ emit({"type":"user","uuid":"peer-message","message":{"role":"user","content":"peer"},"origin":PEER})
60
+ time.sleep(1.5)
61
+ result("peer turn done", PEER)
62
+ continue
44
63
  if text == "permission":
45
64
  pending = text
46
65
  emit({"type":"control_request","request_id":"permission-1",
@@ -164,3 +183,63 @@ async def test_permission_waits_for_durable_explicit_answer(tmp_path, native):
164
183
  runtime.store.answer(eid, requests[0]["id"], {"decision": "allow"})
165
184
  finally:
166
185
  await runtime.stop(eid)
186
+
187
+
188
+ async def test_input_folded_into_a_peer_turn_settles_with_that_turn(tmp_path, native):
189
+ runtime = ClaudeRuntime(tmp_path / "runtime")
190
+ opts = options(tmp_path, native)
191
+ value = await runtime.start(
192
+ "folded-into-peer-turn", message_id="m1", options=opts, launch_id="folded"
193
+ )
194
+ sid, eid = value["session_id"], value["execution_id"]
195
+
196
+ def settled():
197
+ return [
198
+ e["event"] for e in runtime.store.events(sid) if e["event"]["type"] == "work.settled"
199
+ ]
200
+
201
+ try:
202
+ await wait_for(lambda: len(settled()) >= 2)
203
+ # The first result came before the input's echo, so it is not the input's result.
204
+ assert [event["message_ids"] for event in settled()] == [[], ["m1"]]
205
+ assert settled()[1]["message"] == "answer to both"
206
+ # The worker is free again: it takes the next input.
207
+ await runtime.submit(sid, "next", message_id="m2", options=opts, resume=False)
208
+ await wait_for(lambda: len(settled()) >= 3)
209
+ assert settled()[2]["message_ids"] == ["m2"]
210
+ finally:
211
+ await runtime.stop(eid)
212
+
213
+
214
+ async def test_a_turn_for_another_origin_is_not_ended_by_the_idle_timeout(tmp_path, native):
215
+ runtime = ClaudeRuntime(tmp_path / "runtime")
216
+ opts = LaunchOptions(
217
+ cwd=tmp_path,
218
+ claude=native,
219
+ idle_timeout_seconds=0.5,
220
+ environment={"FAKE_CLAUDE_LOG": str(tmp_path / "inputs.jsonl")},
221
+ )
222
+ value = await runtime.start("then-a-peer-turn", message_id="m1", options=opts, launch_id="peer")
223
+ sid, eid = value["session_id"], value["execution_id"]
224
+ try:
225
+ await wait_for(lambda: runtime.inspect(eid)["result"])
226
+ events = [e["event"] for e in runtime.store.events(sid)]
227
+ kinds = [
228
+ e["type"]
229
+ for e in events
230
+ if e["type"] in {"turn.started", "work.settled", "worker.exited"}
231
+ ]
232
+ # The worker saw the peer turn start and waited for its result before its idle exit.
233
+ assert kinds == [
234
+ "turn.started",
235
+ "work.settled",
236
+ "turn.started",
237
+ "work.settled",
238
+ "worker.exited",
239
+ ]
240
+ assert events[-1]["reason"] == "idle_timeout"
241
+ settled = [e for e in events if e["type"] == "work.settled"]
242
+ assert [e["message_ids"] for e in settled] == [["m1"], []]
243
+ assert settled[1]["message"] == "peer turn done"
244
+ finally:
245
+ await runtime.stop(eid)
@@ -0,0 +1,179 @@
1
+ """The hooks against hand-written payloads in the native Codex and Claude shapes."""
2
+
3
+ import json
4
+ import socket
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import time
9
+ import uuid
10
+ from pathlib import Path
11
+
12
+ import pytest
13
+
14
+ from agent_runtime.claude import process_alive
15
+ from agent_runtime.hooks import app
16
+ from agent_runtime.sessions import Registry, own_claude_registration
17
+
18
+
19
+ def wait_for(predicate, timeout=15):
20
+ deadline = time.monotonic() + timeout
21
+ while time.monotonic() < deadline:
22
+ if result := predicate():
23
+ return result
24
+ time.sleep(0.05)
25
+ raise AssertionError("condition did not become true")
26
+
27
+
28
+ def codex_payload(event: str, session_id: str, **fields) -> dict:
29
+ payload = {
30
+ "session_id": session_id,
31
+ "transcript_path": None,
32
+ "cwd": "/repo",
33
+ "hook_event_name": event,
34
+ "model": "gpt-5",
35
+ "permission_mode": "default",
36
+ }
37
+ if event == "SessionStart":
38
+ payload["source"] = "startup"
39
+ elif event == "UserPromptSubmit":
40
+ payload.update(turn_id="t", prompt="hello")
41
+ elif event == "PreToolUse":
42
+ payload.update(
43
+ turn_id="t", tool_name="Bash", tool_use_id="u", tool_input={"command": "echo ok"}
44
+ )
45
+ return {**payload, **fields}
46
+
47
+
48
+ @pytest.mark.parametrize("event", ["SessionStart", "UserPromptSubmit", "PreToolUse"])
49
+ def test_codex_events_register_the_session(tmp_path, monkeypatch, event):
50
+ monkeypatch.setenv("AGENT_RUNTIME_DIRECTORY", str(tmp_path))
51
+ session_id = str(uuid.uuid4())
52
+
53
+ assert app.handle("codex", codex_payload(event, session_id)) is None
54
+
55
+ record = Registry(tmp_path).get("codex", session_id)
56
+ assert record["cwd"] == "/repo"
57
+ assert record["permission_mode"] == "default"
58
+
59
+
60
+ @pytest.mark.parametrize("host", ["codex", "claude"])
61
+ def test_subagent_events_do_not_register(tmp_path, monkeypatch, host):
62
+ monkeypatch.setenv("AGENT_RUNTIME_DIRECTORY", str(tmp_path))
63
+ monkeypatch.delenv("CLAUDE_PID", raising=False)
64
+ payload = codex_payload("PreToolUse", "parent", agent_id="a", agent_type="explore")
65
+ if host == "claude":
66
+ payload["transcript_path"] = str(tmp_path / "t.jsonl")
67
+
68
+ assert app.handle(host, payload) is None
69
+
70
+ for provider in ("codex", "claude_code"):
71
+ with pytest.raises(KeyError):
72
+ Registry(tmp_path).get(provider, "parent")
73
+
74
+
75
+ def test_hooks_do_not_import_the_claude_sdk():
76
+ # Hook environments install agent-runtime-py without the `claude` extra.
77
+ code = "import sys, agent_runtime.hooks; print('claude_agent_sdk' in sys.modules)"
78
+ result = subprocess.run(
79
+ [sys.executable, "-c", code], capture_output=True, text=True, check=True
80
+ )
81
+ assert result.stdout == "False\n"
82
+
83
+
84
+ def test_hook_command_reads_host_argument_and_stdin(tmp_path):
85
+ session_id = str(uuid.uuid4())
86
+ result = subprocess.run(
87
+ [
88
+ sys.executable,
89
+ "-c",
90
+ "from agent_runtime.hooks import app\nif __name__ == '__main__':\n app.main()",
91
+ "codex",
92
+ ],
93
+ input=json.dumps(codex_payload("SessionStart", session_id)),
94
+ env={"AGENT_RUNTIME_DIRECTORY": str(tmp_path), "PATH": "/usr/bin:/bin"},
95
+ capture_output=True,
96
+ text=True,
97
+ check=True,
98
+ )
99
+
100
+ assert (result.stdout, result.stderr) == ("", "")
101
+ assert Registry(tmp_path).get("codex", session_id)["cwd"] == "/repo"
102
+
103
+
104
+ def test_claude_session_start_starts_one_delivery_bridge(tmp_path, monkeypatch):
105
+ session_id = str(uuid.uuid4())
106
+ payload = {
107
+ "session_id": session_id,
108
+ "transcript_path": str(tmp_path / "t.jsonl"),
109
+ "cwd": "/repo",
110
+ "hook_event_name": "SessionStart",
111
+ "source": "startup",
112
+ "model": "claude",
113
+ }
114
+ owner = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(300)"])
115
+ try:
116
+ with (
117
+ tempfile.TemporaryDirectory(prefix="ar-hook-", dir="/tmp") as temporary,
118
+ socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as inbox,
119
+ ):
120
+ inbox.bind(str(Path(temporary) / "inbox.sock"))
121
+ inbox.listen()
122
+ for name, value in {
123
+ "AGENT_RUNTIME_DIRECTORY": str(tmp_path),
124
+ "CLAUDE_CODE_SESSION_ID": session_id,
125
+ "CLAUDE_PID": str(owner.pid),
126
+ "CLAUDE_CODE_MESSAGING_SOCKET": str(Path(temporary) / "inbox.sock"),
127
+ "CLAUDE_CODE_MESSAGING_TOKEN": "fixture-token",
128
+ }.items():
129
+ monkeypatch.setenv(name, value)
130
+
131
+ assert app.handle("claude", payload) is None
132
+ registry = Registry(tmp_path)
133
+ # The hook records the bridge before it returns.
134
+ bridge = registry.get("claude_code", session_id)["bridge"]
135
+ assert bridge["native_pid"] == owner.pid
136
+ assert process_alive(bridge)
137
+
138
+ prompt = {
139
+ **payload,
140
+ "hook_event_name": "UserPromptSubmit",
141
+ "prompt": "hi",
142
+ "permission_mode": "default",
143
+ }
144
+ assert app.handle("claude", prompt) is None
145
+ assert registry.get("claude_code", session_id)["bridge"] == bridge
146
+
147
+ # SessionStart after compaction has no permission mode.
148
+ assert app.handle("claude", {**payload, "source": "compact"}) is None
149
+ assert registry.get("claude_code", session_id)["permission_mode"] == "default"
150
+ finally:
151
+ owner.terminate()
152
+ owner.wait(timeout=5)
153
+ # The bridge exits when its native owner exits.
154
+ wait_for(lambda: not process_alive(bridge))
155
+
156
+
157
+ def test_permission_mode_belongs_to_the_native_process(tmp_path, monkeypatch):
158
+ registry = Registry(tmp_path)
159
+ monkeypatch.setenv("CLAUDE_CODE_MESSAGING_SOCKET", str(tmp_path / "inbox.sock"))
160
+ monkeypatch.delenv("CLAUDE_CODE_SESSION_ID", raising=False)
161
+ sleeper = [sys.executable, "-c", "import time; time.sleep(300)"]
162
+ first = subprocess.Popen(sleeper)
163
+ try:
164
+ monkeypatch.setenv("CLAUDE_PID", str(first.pid))
165
+ own_claude_registration("s", "/repo", "default", registry=registry)
166
+ # SessionStart after compaction: same process, no mode in the payload.
167
+ own_claude_registration("s", "/repo", None, registry=registry)
168
+ assert registry.get("claude_code", "s")["permission_mode"] == "default"
169
+ finally:
170
+ first.terminate()
171
+ first.wait(timeout=5)
172
+ resumed = subprocess.Popen(sleeper)
173
+ try:
174
+ monkeypatch.setenv("CLAUDE_PID", str(resumed.pid))
175
+ own_claude_registration("s", "/repo", None, registry=registry)
176
+ assert registry.get("claude_code", "s")["permission_mode"] is None
177
+ finally:
178
+ resumed.terminate()
179
+ resumed.wait(timeout=5)
@@ -1,6 +1,8 @@
1
1
  """Real detached shell monitors and native inbox frames at a local socket boundary."""
2
2
 
3
3
  import json
4
+ import os
5
+ import signal
4
6
  import socket
5
7
  import subprocess
6
8
  import sys
@@ -8,6 +10,7 @@ import tempfile
8
10
  import threading
9
11
  import time
10
12
  import uuid
13
+ from contextlib import suppress
11
14
  from pathlib import Path
12
15
 
13
16
  import psutil
@@ -50,7 +53,7 @@ def inbox(tmp_path, monkeypatch):
50
53
  data += block
51
54
  frames.append([json.loads(line) for line in data.splitlines()])
52
55
 
53
- thread = threading.Thread(target=receive)
56
+ thread = threading.Thread(target=receive, daemon=True)
54
57
  thread.start()
55
58
  owner = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(300)"])
56
59
  sid = str(uuid.uuid4())
@@ -75,9 +78,18 @@ def inbox(tmp_path, monkeypatch):
75
78
  finally:
76
79
  for monitor in monitors.list(sid):
77
80
  monitors.stop(monitor["monitor_id"])
78
- wait_for(
79
- lambda: all(m["status"] not in {"starting", "running"} for m in monitors.list(sid))
80
- )
81
+ deadline = time.monotonic() + 15
82
+ while time.monotonic() < deadline and any(map(process_alive, monitors.list(sid))):
83
+ time.sleep(0.05)
84
+ # Kill a stuck worker and its command, so that a failing test leaks
85
+ # no process, and fail after the rest of the cleanup.
86
+ stuck = [m for m in monitors.list(sid) if process_alive(m)]
87
+ for monitor in stuck:
88
+ with suppress(psutil.NoSuchProcess):
89
+ worker = psutil.Process(monitor["pid"])
90
+ for process in [*worker.children(recursive=True), worker]:
91
+ with suppress(psutil.NoSuchProcess):
92
+ process.kill()
81
93
  if owner.poll() is None:
82
94
  owner.terminate()
83
95
  owner.wait(timeout=5)
@@ -85,6 +97,7 @@ def inbox(tmp_path, monkeypatch):
85
97
  stopped.set()
86
98
  thread.join(timeout=2)
87
99
  sock.close()
100
+ assert not stuck, f"monitor workers did not exit: {[m['monitor_id'] for m in stuck]}"
88
101
 
89
102
 
90
103
  def test_shell_output_failure_and_truthful_peer_mode(inbox):
@@ -121,7 +134,7 @@ def test_deadline_and_stop_kill_foreground_process_group(inbox, tmp_path):
121
134
  monitor = monitors.start(
122
135
  f"sleep 30 & echo $! > {pidfile}; wait", description="stop tree", persistent=True
123
136
  )
124
- wait_for(pidfile.exists)
137
+ wait_for(lambda: pidfile.exists() and pidfile.read_text().strip())
125
138
  child = psutil.Process(int(pidfile.read_text()))
126
139
  identity = {"pid": child.pid, "process_created_at": child.create_time()}
127
140
  monitors.stop(monitor["monitor_id"])
@@ -190,3 +203,25 @@ def test_excessive_output_stops_with_inspectable_error(inbox):
190
203
  lambda: m if (m := monitors.get(monitor["monitor_id"]))["status"] == "failed" else None
191
204
  )
192
205
  assert "exceeded 1 MiB" in done["error"]
206
+ # The unread output must not keep the worker waiting for its command.
207
+ wait_for(lambda: not process_alive(done))
208
+
209
+
210
+ def test_worker_stops_its_command_and_exits_on_sigterm(inbox, tmp_path):
211
+ monitors, _sid, _, _, _ = inbox
212
+ pidfile = tmp_path / "command.pid"
213
+ monitor = monitors.start(
214
+ f"echo $$ > {pidfile}; exec sleep 300", description="sigterm", persistent=True
215
+ )
216
+ wait_for(lambda: pidfile.exists() and pidfile.read_text().strip())
217
+ command = psutil.Process(int(pidfile.read_text()))
218
+ command_identity = {"pid": command.pid, "process_created_at": command.create_time()}
219
+ worker = wait_for(
220
+ lambda: m if (m := monitors.get(monitor["monitor_id"]))["status"] == "running" else None
221
+ )
222
+
223
+ os.kill(worker["pid"], signal.SIGTERM)
224
+
225
+ wait_for(lambda: not process_alive(worker))
226
+ assert not process_alive(command_identity)
227
+ assert monitors.get(monitor["monitor_id"])["status"] == "stopped"
@@ -1,26 +1,31 @@
1
1
  version = 1
2
- revision = 3
2
+ revision = 5
3
3
  requires-python = ">=3.11"
4
4
  resolution-markers = [
5
5
  "python_full_version >= '3.14'",
6
6
  "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'",
7
- "(python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version < '3.12' and sys_platform == 'emscripten')",
7
+ "python_full_version < '3.12' or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten')",
8
8
  ]
9
9
 
10
10
  [[package]]
11
11
  name = "agent-runtime-py"
12
- version = "0.1.1"
12
+ version = "0.2.1"
13
13
  source = { editable = "." }
14
14
  dependencies = [
15
- { name = "claude-agent-sdk" },
16
15
  { name = "filelock" },
17
16
  { name = "platformdirs" },
18
17
  { name = "psutil" },
19
18
  { name = "typed-agent-hooks" },
20
19
  ]
21
20
 
21
+ [package.optional-dependencies]
22
+ claude = [
23
+ { name = "claude-agent-sdk" },
24
+ ]
25
+
22
26
  [package.dev-dependencies]
23
27
  dev = [
28
+ { name = "agent-runtime-py", extra = ["claude"] },
24
29
  { name = "basedpyright" },
25
30
  { name = "build" },
26
31
  { name = "pytest" },
@@ -30,15 +35,17 @@ dev = [
30
35
 
31
36
  [package.metadata]
32
37
  requires-dist = [
33
- { name = "claude-agent-sdk", specifier = "==0.2.159" },
38
+ { name = "claude-agent-sdk", marker = "extra == 'claude'", specifier = "==0.2.159" },
34
39
  { name = "filelock", specifier = ">=3.16,<4" },
35
40
  { name = "platformdirs", specifier = ">=4,<5" },
36
41
  { name = "psutil", specifier = ">=7,<8" },
37
- { name = "typed-agent-hooks" },
42
+ { name = "typed-agent-hooks", specifier = ">=0.3,<0.4" },
38
43
  ]
44
+ provides-extras = ["claude"]
39
45
 
40
46
  [package.metadata.requires-dev]
41
47
  dev = [
48
+ { name = "agent-runtime-py", extras = ["claude"] },
42
49
  { name = "basedpyright", specifier = ">=1.31,<2" },
43
50
  { name = "build", specifier = ">=1.3,<2" },
44
51
  { name = "pytest", specifier = ">=8.4,<9" },
@@ -294,30 +301,6 @@ wheels = [
294
301
  { url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" },
295
302
  ]
296
303
 
297
- [[package]]
298
- name = "cyclopts"
299
- version = "4.25.3"
300
- source = { registry = "https://pypi.org/simple" }
301
- dependencies = [
302
- { name = "attrs" },
303
- { name = "docstring-parser" },
304
- { name = "rich" },
305
- { name = "rich-rst" },
306
- ]
307
- sdist = { url = "https://files.pythonhosted.org/packages/c1/08/a444f3cca86cb27bc256831fc8c42b578390f4087bf6631cccb5334bd9ed/cyclopts-4.25.3.tar.gz", hash = "sha256:7d3a067bc7699a1a65fe2ea0b092a925855bb711a05adc1e65c5b9ba307acea4", size = 202814, upload-time = "2026-09-17T16:25:16.892Z" }
308
- wheels = [
309
- { url = "https://files.pythonhosted.org/packages/a9/cc/e879da3d54cf727f90e6d90118e132672c3913338b566740486f8797190d/cyclopts-4.25.3-py3-none-any.whl", hash = "sha256:40e76b3153ea90e885abd8d3a4d8184db5a5eec2fde8540025aae8d72fc973fc", size = 242748, upload-time = "2026-09-17T16:25:15.509Z" },
310
- ]
311
-
312
- [[package]]
313
- name = "docstring-parser"
314
- version = "0.18.0"
315
- source = { registry = "https://pypi.org/simple" }
316
- sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" }
317
- wheels = [
318
- { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" },
319
- ]
320
-
321
304
  [[package]]
322
305
  name = "filelock"
323
306
  version = "3.32.7"
@@ -420,18 +403,6 @@ wheels = [
420
403
  { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
421
404
  ]
422
405
 
423
- [[package]]
424
- name = "markdown-it-py"
425
- version = "4.2.0"
426
- source = { registry = "https://pypi.org/simple" }
427
- dependencies = [
428
- { name = "mdurl" },
429
- ]
430
- sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
431
- wheels = [
432
- { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
433
- ]
434
-
435
406
  [[package]]
436
407
  name = "mcp"
437
408
  version = "2.2.0"
@@ -470,15 +441,6 @@ wheels = [
470
441
  { url = "https://files.pythonhosted.org/packages/8f/d7/6ffba5d8cd5dd9b8a19478875c50e04945314ba5074e84d749283f27f62d/mcp_types-2.2.0-py3-none-any.whl", hash = "sha256:ea476b73ee86709ab5abc9452385ed36cc05907e582355622e294595c9a04f13", size = 69106, upload-time = "2026-09-07T16:06:21.461Z" },
471
442
  ]
472
443
 
473
- [[package]]
474
- name = "mdurl"
475
- version = "0.1.2"
476
- source = { registry = "https://pypi.org/simple" }
477
- sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
478
- wheels = [
479
- { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
480
- ]
481
-
482
444
  [[package]]
483
445
  name = "nodejs-wheel-binaries"
484
446
  version = "24.19.0"
@@ -794,32 +756,6 @@ wheels = [
794
756
  { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
795
757
  ]
796
758
 
797
- [[package]]
798
- name = "rich"
799
- version = "15.0.0"
800
- source = { registry = "https://pypi.org/simple" }
801
- dependencies = [
802
- { name = "markdown-it-py" },
803
- { name = "pygments" },
804
- ]
805
- sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
806
- wheels = [
807
- { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
808
- ]
809
-
810
- [[package]]
811
- name = "rich-rst"
812
- version = "2.1.0"
813
- source = { registry = "https://pypi.org/simple" }
814
- dependencies = [
815
- { name = "pygments" },
816
- { name = "rich" },
817
- ]
818
- sdist = { url = "https://files.pythonhosted.org/packages/e2/d6/d0b9fafc73b65767200da027acab1db1bdb1048f4fea5ebf659df01c700e/rich_rst-2.1.0.tar.gz", hash = "sha256:f4d117b49697f338769759fa5cacf5197da4888b347b9fda2e50aef5cd8d93bd", size = 302732, upload-time = "2026-07-05T02:59:44.308Z" }
819
- wheels = [
820
- { url = "https://files.pythonhosted.org/packages/2a/68/1fc93dd759605b5d00fc98b50200739e41ed32bd22d6ba35ca6c3932371b/rich_rst-2.1.0-py3-none-any.whl", hash = "sha256:7ecd1343ee12c879d0e7ae74c3eb6d263b023d2929c6d114212eb1fd91057255", size = 272987, upload-time = "2026-07-05T02:59:42.792Z" },
821
- ]
822
-
823
759
  [[package]]
824
760
  name = "rpds-py"
825
761
  version = "2026.6.3"
@@ -1014,16 +950,14 @@ wheels = [
1014
950
 
1015
951
  [[package]]
1016
952
  name = "typed-agent-hooks"
1017
- version = "0.2.2"
953
+ version = "0.3.0"
1018
954
  source = { registry = "https://pypi.org/simple" }
1019
955
  dependencies = [
1020
- { name = "cyclopts" },
1021
956
  { name = "pydantic" },
1022
- { name = "pydantic-core" },
1023
957
  ]
1024
- sdist = { url = "https://files.pythonhosted.org/packages/c3/f1/4ca2c710dc12d16cc80cf8484b2c5b8f13e01e3b86c6e0be7814c8fbddbd/typed_agent_hooks-0.2.2.tar.gz", hash = "sha256:dce21e3dc6ec274874d61b02f070a13e8bf00f770937c9b2b5e8e67867f0832f", size = 43992, upload-time = "2026-09-24T09:27:05.76Z" }
958
+ sdist = { url = "https://files.pythonhosted.org/packages/cc/0e/770d2d2cca62894fe170fc266f9b1fefbfde0e6ac03a8873a1ea5f6e33b4/typed_agent_hooks-0.3.0.tar.gz", hash = "sha256:344dd64d87b8b76044b96dae9fa4f0ad2430f1ed1597520b7394058a85e83a55", size = 16284, upload-time = "2026-10-27T20:32:39.757Z" }
1025
959
  wheels = [
1026
- { url = "https://files.pythonhosted.org/packages/a3/8a/ce3318d12c87e0899d2bd371333424010ad596874a0cee90c75479cc1c27/typed_agent_hooks-0.2.2-py3-none-any.whl", hash = "sha256:4d89514fc9a2074fb73dab07106a1bb153201e1f608266b21a232a23fa6425af", size = 60031, upload-time = "2026-09-24T09:27:04.384Z" },
960
+ { url = "https://files.pythonhosted.org/packages/de/45/b374fadf78a07adaf99649c3d054d43495dc0038593c3aafcf382397bc5f/typed_agent_hooks-0.3.0-py3-none-any.whl", hash = "sha256:b41020aafc2c913da7f362dfd7570da1fc6b9039d403e8078ea5f5ece8ae5dd2", size = 22930, upload-time = "2026-10-27T20:32:38.47Z" },
1027
961
  ]
1028
962
 
1029
963
  [[package]]