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.
- agent_runtime_py-0.2.1/CHANGELOG.md +14 -0
- {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/PKG-INFO +6 -5
- {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/README.md +2 -2
- {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/pyproject.toml +6 -6
- {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/src/agent_runtime/claude.py +6 -0
- {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/src/agent_runtime/hooks.py +10 -25
- {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/src/agent_runtime/monitor_worker.py +19 -7
- {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/src/agent_runtime/sessions.py +12 -1
- {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/src/agent_runtime/worker.py +22 -1
- {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/tests/test_claude_runtime.py +81 -2
- agent_runtime_py-0.2.1/tests/test_hooks.py +179 -0
- {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/tests/test_monitors.py +40 -5
- {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/uv.lock +16 -82
- {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/.github/workflows/ci.yml +0 -0
- {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/.github/workflows/release.yml +0 -0
- {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/.gitignore +0 -0
- {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/LICENSE +0 -0
- {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/src/agent_runtime/__init__.py +0 -0
- {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/src/agent_runtime/bridge.py +0 -0
- {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/src/agent_runtime/monitors.py +0 -0
- {agent_runtime_py-0.1.1 → agent_runtime_py-0.2.1}/src/agent_runtime/operations.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
22
|
-
|
|
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(
|
|
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
|
-
|
|
23
|
-
if provider == "codex":
|
|
26
|
+
if context.host == "codex":
|
|
24
27
|
registry.update(
|
|
25
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
79
|
-
|
|
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 =
|
|
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
|
-
"
|
|
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.
|
|
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.
|
|
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/
|
|
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/
|
|
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]]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|