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.
@@ -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