agent-runtime-py 0.1.1__tar.gz → 0.2.0__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.0/CHANGELOG.md +8 -0
  2. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.0}/PKG-INFO +6 -5
  3. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.0}/README.md +2 -2
  4. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.0}/pyproject.toml +6 -6
  5. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.0}/src/agent_runtime/claude.py +3 -0
  6. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.0}/src/agent_runtime/hooks.py +10 -25
  7. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.0}/src/agent_runtime/monitor_worker.py +19 -7
  8. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.0}/src/agent_runtime/sessions.py +12 -1
  9. agent_runtime_py-0.2.0/tests/test_hooks.py +179 -0
  10. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.0}/tests/test_monitors.py +40 -5
  11. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.0}/uv.lock +14 -80
  12. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.0}/.github/workflows/ci.yml +0 -0
  13. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.0}/.github/workflows/release.yml +0 -0
  14. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.0}/.gitignore +0 -0
  15. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.0}/LICENSE +0 -0
  16. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.0}/src/agent_runtime/__init__.py +0 -0
  17. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.0}/src/agent_runtime/bridge.py +0 -0
  18. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.0}/src/agent_runtime/monitors.py +0 -0
  19. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.0}/src/agent_runtime/operations.py +0 -0
  20. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.0}/src/agent_runtime/store.py +0 -0
  21. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.0}/src/agent_runtime/worker.py +0 -0
  22. {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.0}/tests/test_claude_runtime.py +0 -0
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ - 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.
6
+ - Breaking: the Claude Agent SDK moves to the `claude` extra. `ClaudeRuntime` needs `agent-runtime-py[claude]`.
7
+ - Fix: a monitor worker whose command exceeded the output limit never exited and ignored SIGTERM.
8
+ - 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.0
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.0"
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]:
@@ -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,
@@ -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"
@@ -9,18 +9,23 @@ resolution-markers = [
9
9
 
10
10
  [[package]]
11
11
  name = "agent-runtime-py"
12
- version = "0.1.1"
12
+ version = "0.2.0"
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]]