bazaar-contrib-claude-code 0.3.0rc1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bazaar_contrib_claude_code/__init__.py +78 -0
- bazaar_contrib_claude_code/approval.py +116 -0
- bazaar_contrib_claude_code/children.py +169 -0
- bazaar_contrib_claude_code/client.py +517 -0
- bazaar_contrib_claude_code/environment.py +7 -0
- bazaar_contrib_claude_code/events.py +690 -0
- bazaar_contrib_claude_code/inspection.py +106 -0
- bazaar_contrib_claude_code/line_reader.py +37 -0
- bazaar_contrib_claude_code/process.py +489 -0
- bazaar_contrib_claude_code/protocol.py +136 -0
- bazaar_contrib_claude_code/py.typed +0 -0
- bazaar_contrib_claude_code/runtime.py +615 -0
- bazaar_contrib_claude_code/skills.py +43 -0
- bazaar_contrib_claude_code/source.py +54 -0
- bazaar_contrib_claude_code/timing.py +18 -0
- bazaar_contrib_claude_code/windows_job.py +103 -0
- bazaar_contrib_claude_code-0.3.0rc1.dist-info/METADATA +43 -0
- bazaar_contrib_claude_code-0.3.0rc1.dist-info/RECORD +21 -0
- bazaar_contrib_claude_code-0.3.0rc1.dist-info/WHEEL +4 -0
- bazaar_contrib_claude_code-0.3.0rc1.dist-info/entry_points.txt +3 -0
- bazaar_contrib_claude_code-0.3.0rc1.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Register the ClaudeCode builder and its privately composed Harness."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, ClassVar
|
|
8
|
+
|
|
9
|
+
from bazaar_contrib_audit import AUDIT
|
|
10
|
+
from bazaar_contrib_harness import (
|
|
11
|
+
HARNESS,
|
|
12
|
+
HARNESS_BUILDERS,
|
|
13
|
+
HARNESS_INPUT,
|
|
14
|
+
HarnessBuilderConfig,
|
|
15
|
+
HarnessInspection,
|
|
16
|
+
harness_builder_key,
|
|
17
|
+
)
|
|
18
|
+
from bazaar_contrib_timer import TIMER
|
|
19
|
+
from bazaar_core import Context, Contrib
|
|
20
|
+
from pydantic import BaseModel, ConfigDict
|
|
21
|
+
|
|
22
|
+
from .inspection import inspect
|
|
23
|
+
from .runtime import ClaudeCodeHarness
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Config(BaseModel):
|
|
27
|
+
model_config = ConfigDict(frozen=True)
|
|
28
|
+
model: str | None = None
|
|
29
|
+
effort: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@asynccontextmanager
|
|
33
|
+
async def lifespan(ctx: Context, config: Config):
|
|
34
|
+
instance = ClaudeCodeHarness(
|
|
35
|
+
ctx.accept(HARNESS_INPUT),
|
|
36
|
+
ctx.accept(TIMER),
|
|
37
|
+
ctx.accept(AUDIT),
|
|
38
|
+
config.model,
|
|
39
|
+
config.effort,
|
|
40
|
+
)
|
|
41
|
+
ctx.offer(HARNESS, instance)
|
|
42
|
+
try:
|
|
43
|
+
yield
|
|
44
|
+
finally:
|
|
45
|
+
await instance.aclose()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
harness = Contrib(
|
|
49
|
+
config=Config,
|
|
50
|
+
accepts=[HARNESS_INPUT, TIMER, AUDIT],
|
|
51
|
+
offers=[HARNESS],
|
|
52
|
+
lifespan=lifespan,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class BuilderConfig(HarnessBuilderConfig):
|
|
57
|
+
kind: ClassVar[str] = "claudecode"
|
|
58
|
+
entity_model: ClassVar[type[BaseModel]] = Config
|
|
59
|
+
entry: ClassVar[Contrib[Any]] = harness
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
async def inspect(cls, workspace: Path, *, timeout: float) -> HarnessInspection: # noqa: ASYNC109
|
|
63
|
+
return await inspect(workspace, timeout=timeout)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@asynccontextmanager
|
|
67
|
+
async def builder_lifespan(ctx: Context, config: BuilderConfig):
|
|
68
|
+
with ctx.accept(HARNESS_BUILDERS).register(config.builder()) as builder:
|
|
69
|
+
ctx.offer(harness_builder_key(builder.kind), builder)
|
|
70
|
+
yield
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
builder = Contrib(
|
|
74
|
+
config=BuilderConfig,
|
|
75
|
+
accepts=[HARNESS_BUILDERS],
|
|
76
|
+
offers=[harness_builder_key("claudecode")],
|
|
77
|
+
lifespan=builder_lifespan,
|
|
78
|
+
)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from time import time_ns
|
|
7
|
+
|
|
8
|
+
from bazaar_contrib_harness import ApprovalRequest, ApprovalResult
|
|
9
|
+
from pydantic import JsonValue
|
|
10
|
+
|
|
11
|
+
from .protocol import ClaudeProtocolError, JsonObject
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class ApprovalEnvelope:
|
|
16
|
+
"""Claude permission request plus its provider-neutral projection."""
|
|
17
|
+
|
|
18
|
+
control_request_id: str
|
|
19
|
+
tool_name: str
|
|
20
|
+
tool_input: Mapping[str, object]
|
|
21
|
+
request: ApprovalRequest
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_approval_request(
|
|
25
|
+
message: Mapping[str, object],
|
|
26
|
+
*,
|
|
27
|
+
session_id: str,
|
|
28
|
+
turn_id: str,
|
|
29
|
+
) -> ApprovalEnvelope:
|
|
30
|
+
if message.get("type") != "control_request":
|
|
31
|
+
raise ClaudeProtocolError("message is not a control request")
|
|
32
|
+
control_request_id = message.get("request_id")
|
|
33
|
+
if not isinstance(control_request_id, str) or not control_request_id:
|
|
34
|
+
raise ClaudeProtocolError("permission control request requires a request_id")
|
|
35
|
+
request = message.get("request")
|
|
36
|
+
if not isinstance(request, Mapping) or request.get("subtype") != "can_use_tool":
|
|
37
|
+
raise ClaudeProtocolError("unsupported Claude control request subtype")
|
|
38
|
+
tool_use_id = request.get("tool_use_id")
|
|
39
|
+
if not isinstance(tool_use_id, str) or not tool_use_id:
|
|
40
|
+
raise ClaudeProtocolError("permission request requires a tool_use_id")
|
|
41
|
+
tool_name = request.get("tool_name")
|
|
42
|
+
if not isinstance(tool_name, str) or not tool_name:
|
|
43
|
+
raise ClaudeProtocolError("permission request requires a tool_name")
|
|
44
|
+
tool_input = request.get("input")
|
|
45
|
+
if not isinstance(tool_input, Mapping):
|
|
46
|
+
raise ClaudeProtocolError("permission request input must be an object")
|
|
47
|
+
|
|
48
|
+
details = _details(request, tool_input)
|
|
49
|
+
return ApprovalEnvelope(
|
|
50
|
+
control_request_id=control_request_id,
|
|
51
|
+
tool_name=tool_name,
|
|
52
|
+
tool_input=tool_input,
|
|
53
|
+
request=ApprovalRequest(
|
|
54
|
+
request_id=tool_use_id,
|
|
55
|
+
session_id=session_id,
|
|
56
|
+
action=tool_name,
|
|
57
|
+
created_at_ms=time_ns() // 1_000_000,
|
|
58
|
+
turn_id=turn_id,
|
|
59
|
+
details=details,
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_approval_response(
|
|
65
|
+
approval: ApprovalEnvelope, result: ApprovalResult
|
|
66
|
+
) -> JsonObject:
|
|
67
|
+
if result.request_id != approval.request.request_id:
|
|
68
|
+
raise ValueError("approval result request id does not match tool use")
|
|
69
|
+
if result.decision == "approved":
|
|
70
|
+
return {"behavior": "allow", "updatedInput": dict(approval.tool_input)}
|
|
71
|
+
return {
|
|
72
|
+
"behavior": "deny",
|
|
73
|
+
"message": result.reason or "Permission denied by the user.",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_MAX_APPROVAL_DETAIL = 4_000
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _details(
|
|
81
|
+
request: Mapping[str, object], tool_input: Mapping[str, object]
|
|
82
|
+
) -> dict[str, JsonValue]:
|
|
83
|
+
reasons: list[str] = []
|
|
84
|
+
for field_name in ("description", "title", "display_name", "decision_reason"):
|
|
85
|
+
value = request.get(field_name)
|
|
86
|
+
if isinstance(value, str) and value.strip() and value.strip() not in reasons:
|
|
87
|
+
reasons.append(value.strip())
|
|
88
|
+
try:
|
|
89
|
+
encoded_input = json.dumps(
|
|
90
|
+
dict(tool_input),
|
|
91
|
+
ensure_ascii=False,
|
|
92
|
+
sort_keys=True,
|
|
93
|
+
separators=(",", ":"),
|
|
94
|
+
)
|
|
95
|
+
except TypeError, ValueError:
|
|
96
|
+
encoded_input = ""
|
|
97
|
+
details: dict[str, JsonValue] = {}
|
|
98
|
+
if reasons:
|
|
99
|
+
details["reason"] = "\n".join(reasons)
|
|
100
|
+
tool_name = request["tool_name"]
|
|
101
|
+
if isinstance(tool_name, str) and tool_name:
|
|
102
|
+
details["tool_name"] = tool_name
|
|
103
|
+
if encoded_input:
|
|
104
|
+
details["tool_input"] = (
|
|
105
|
+
encoded_input[: _MAX_APPROVAL_DETAIL - 1] + "…"
|
|
106
|
+
if len(encoded_input) > _MAX_APPROVAL_DETAIL
|
|
107
|
+
else encoded_input
|
|
108
|
+
)
|
|
109
|
+
return details
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
__all__ = [
|
|
113
|
+
"ApprovalEnvelope",
|
|
114
|
+
"build_approval_response",
|
|
115
|
+
"parse_approval_request",
|
|
116
|
+
]
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""The runtime processes this provider started and that have not ended: when
|
|
2
|
+
going down runs out of time, the node ends them itself before it exits, so
|
|
3
|
+
none is left running without it. Each has its own group or Windows Job, so
|
|
4
|
+
what it starts in turn ends with it."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import os
|
|
10
|
+
import signal
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import psutil
|
|
17
|
+
from bazaar_contrib_timer import Timer
|
|
18
|
+
|
|
19
|
+
if sys.platform == "win32":
|
|
20
|
+
from .windows_job import CREATE_SUSPENDED, WindowsJob
|
|
21
|
+
|
|
22
|
+
# how long a runtime asked to end gets before it is killed
|
|
23
|
+
END_GRACE_SECONDS = 2.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def own_group() -> dict[str, Any]:
|
|
27
|
+
"""What `create_subprocess_exec` takes to start a process in a group of
|
|
28
|
+
its own: the terminal's Ctrl-C does not reach it, the node ends it.
|
|
29
|
+
On Windows, Children.started assigns the suspended process before resuming."""
|
|
30
|
+
|
|
31
|
+
if sys.platform == "win32":
|
|
32
|
+
return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP | CREATE_SUSPENDED}
|
|
33
|
+
return {"start_new_session": True}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Children:
|
|
37
|
+
def __init__(self, timer: Timer | None = None):
|
|
38
|
+
self.timer = timer
|
|
39
|
+
self._running: set[int] = set()
|
|
40
|
+
self._reapers: set[asyncio.Task[None]] = set()
|
|
41
|
+
if sys.platform == "win32":
|
|
42
|
+
self._jobs: dict[int, WindowsJob] = {}
|
|
43
|
+
|
|
44
|
+
def started(self, pid: int) -> None:
|
|
45
|
+
self._running.add(pid)
|
|
46
|
+
if sys.platform == "win32":
|
|
47
|
+
# Own the entire family before the CLI can create any descendants.
|
|
48
|
+
job = WindowsJob()
|
|
49
|
+
self._jobs[pid] = job
|
|
50
|
+
job.assign(pid)
|
|
51
|
+
psutil.Process(pid).resume()
|
|
52
|
+
|
|
53
|
+
def reaping(self, task: asyncio.Task[None]) -> None:
|
|
54
|
+
self._reapers.add(task)
|
|
55
|
+
task.add_done_callback(self._reapers.discard)
|
|
56
|
+
|
|
57
|
+
def snapshot(self, pid: int) -> tuple[psutil.Process, ...]:
|
|
58
|
+
# Cancellation must not kill the root before its descendants are captured.
|
|
59
|
+
try:
|
|
60
|
+
process = psutil.Process(pid)
|
|
61
|
+
descendants = process.children(recursive=True)
|
|
62
|
+
except psutil.NoSuchProcess:
|
|
63
|
+
return ()
|
|
64
|
+
return (process, *descendants)
|
|
65
|
+
|
|
66
|
+
async def ended(self, pid: int, tree: Sequence[psutil.Process] = ()) -> None:
|
|
67
|
+
"""A runtime is gone: what it started and left running goes with it, so
|
|
68
|
+
nothing it began outlives it unregistered."""
|
|
69
|
+
|
|
70
|
+
if sys.platform == "win32":
|
|
71
|
+
if job := self._jobs.get(pid):
|
|
72
|
+
job.close()
|
|
73
|
+
del self._jobs[pid]
|
|
74
|
+
else:
|
|
75
|
+
await asyncio.to_thread(_signal_group, pid, signal.SIGKILL)
|
|
76
|
+
await asyncio.to_thread(_kill_tree, tree)
|
|
77
|
+
self._running.discard(pid)
|
|
78
|
+
|
|
79
|
+
async def wait(self, tree: Sequence[psutil.Process]) -> None:
|
|
80
|
+
remaining = list(tree)
|
|
81
|
+
while remaining:
|
|
82
|
+
_, remaining = await asyncio.to_thread(
|
|
83
|
+
psutil.wait_procs, remaining, timeout=0
|
|
84
|
+
)
|
|
85
|
+
if remaining:
|
|
86
|
+
if self.timer is None:
|
|
87
|
+
await asyncio.sleep(0.05)
|
|
88
|
+
else:
|
|
89
|
+
await self.timer.create(50).wait()
|
|
90
|
+
|
|
91
|
+
async def end_all(self) -> None:
|
|
92
|
+
"""End every runtime still running, with what it started: asked first,
|
|
93
|
+
killed when it is still there after the grace."""
|
|
94
|
+
|
|
95
|
+
pids = tuple(self._running)
|
|
96
|
+
if not pids:
|
|
97
|
+
await asyncio.gather(*tuple(self._reapers), return_exceptions=True)
|
|
98
|
+
return
|
|
99
|
+
trees: dict[int, tuple[psutil.Process, ...]] = {}
|
|
100
|
+
|
|
101
|
+
def snapshot() -> None:
|
|
102
|
+
for pid in pids:
|
|
103
|
+
trees[pid] = self.snapshot(pid)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
capture = asyncio.create_task(asyncio.to_thread(snapshot))
|
|
107
|
+
try:
|
|
108
|
+
await asyncio.shield(capture)
|
|
109
|
+
except asyncio.CancelledError:
|
|
110
|
+
await capture
|
|
111
|
+
raise
|
|
112
|
+
if sys.platform == "win32":
|
|
113
|
+
return
|
|
114
|
+
for pid in pids:
|
|
115
|
+
await asyncio.to_thread(_signal_group, pid, signal.SIGTERM)
|
|
116
|
+
loop = asyncio.get_running_loop()
|
|
117
|
+
deadline = loop.time() + END_GRACE_SECONDS
|
|
118
|
+
roots = [tree[0] for tree in trees.values() if tree]
|
|
119
|
+
remaining = [process for tree in trees.values() for process in tree[1:]]
|
|
120
|
+
while loop.time() < deadline:
|
|
121
|
+
_, remaining = await asyncio.to_thread(
|
|
122
|
+
psutil.wait_procs, remaining, timeout=0
|
|
123
|
+
)
|
|
124
|
+
if not remaining and not await asyncio.to_thread(
|
|
125
|
+
lambda: any(root.is_running() for root in roots)
|
|
126
|
+
):
|
|
127
|
+
break
|
|
128
|
+
if self.timer is None:
|
|
129
|
+
await asyncio.sleep(0.05)
|
|
130
|
+
else:
|
|
131
|
+
await self.timer.create(50).wait()
|
|
132
|
+
finally:
|
|
133
|
+
for pid in pids:
|
|
134
|
+
await self.ended(pid, trees.get(pid, ()))
|
|
135
|
+
await asyncio.gather(*tuple(self._reapers), return_exceptions=True)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _kill_tree(tree: Sequence[psutil.Process]) -> None:
|
|
139
|
+
for process in reversed(tree):
|
|
140
|
+
try:
|
|
141
|
+
process.kill()
|
|
142
|
+
except psutil.NoSuchProcess:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if sys.platform != "win32":
|
|
147
|
+
|
|
148
|
+
def _signal_group(pid: int, number: signal.Signals) -> None:
|
|
149
|
+
try:
|
|
150
|
+
os.killpg(pid, number)
|
|
151
|
+
except ProcessLookupError:
|
|
152
|
+
pass
|
|
153
|
+
except PermissionError:
|
|
154
|
+
if sys.platform != "darwin" or _group_has_live_members(pid):
|
|
155
|
+
raise
|
|
156
|
+
|
|
157
|
+
def _group_has_live_members(pid: int) -> bool:
|
|
158
|
+
# Darwin also returns EPERM for a group containing only zombies. Check
|
|
159
|
+
# the kernel's process list before treating a permission error as exit.
|
|
160
|
+
for process in psutil.process_iter(("pid", "status")):
|
|
161
|
+
try:
|
|
162
|
+
if (
|
|
163
|
+
process.info["status"] != psutil.STATUS_ZOMBIE
|
|
164
|
+
and os.getpgid(process.pid) == pid
|
|
165
|
+
):
|
|
166
|
+
return True
|
|
167
|
+
except ProcessLookupError:
|
|
168
|
+
pass
|
|
169
|
+
return False
|