patchfeld 0.2.0__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.
- patchfeld/__init__.py +1 -0
- patchfeld/__main__.py +32 -0
- patchfeld/actions.py +34 -0
- patchfeld/activity/__init__.py +0 -0
- patchfeld/activity/log.py +237 -0
- patchfeld/agents/__init__.py +0 -0
- patchfeld/agents/child_tools.py +66 -0
- patchfeld/agents/fake_sdk_adapter.py +45 -0
- patchfeld/agents/manager.py +365 -0
- patchfeld/agents/permission_grants.py +98 -0
- patchfeld/agents/permission_inbox.py +91 -0
- patchfeld/agents/request_inbox.py +65 -0
- patchfeld/agents/sdk_adapter.py +49 -0
- patchfeld/agents/session.py +250 -0
- patchfeld/agents/sort.py +66 -0
- patchfeld/agents/state.py +81 -0
- patchfeld/app.py +1433 -0
- patchfeld/config.py +128 -0
- patchfeld/events.py +260 -0
- patchfeld/layout/__init__.py +0 -0
- patchfeld/layout/custom_widgets.py +82 -0
- patchfeld/layout/defaults.py +33 -0
- patchfeld/layout/engine.py +241 -0
- patchfeld/layout/local_widgets.py +188 -0
- patchfeld/layout/registry.py +69 -0
- patchfeld/layout/spec.py +104 -0
- patchfeld/layout/splitter.py +170 -0
- patchfeld/layout/titles.py +70 -0
- patchfeld/orchestrator/__init__.py +0 -0
- patchfeld/orchestrator/formatting.py +15 -0
- patchfeld/orchestrator/session.py +785 -0
- patchfeld/orchestrator/tabs_tools.py +149 -0
- patchfeld/orchestrator/tools.py +976 -0
- patchfeld/persistence/__init__.py +0 -0
- patchfeld/persistence/agents_index.py +68 -0
- patchfeld/persistence/atomic.py +47 -0
- patchfeld/persistence/layout_store.py +25 -0
- patchfeld/persistence/layouts_store.py +61 -0
- patchfeld/persistence/orchestrator_sessions.py +127 -0
- patchfeld/persistence/paths.py +48 -0
- patchfeld/persistence/themes_store.py +44 -0
- patchfeld/persistence/transcript_store.py +64 -0
- patchfeld/persistence/workspace_store.py +25 -0
- patchfeld/theme/__init__.py +0 -0
- patchfeld/theme/engine.py +75 -0
- patchfeld/theme/spec.py +31 -0
- patchfeld/widgets/__init__.py +0 -0
- patchfeld/widgets/_file_lang.py +36 -0
- patchfeld/widgets/_terminal_keys.py +89 -0
- patchfeld/widgets/_terminal_render.py +147 -0
- patchfeld/widgets/activity_feed.py +365 -0
- patchfeld/widgets/agent_table.py +236 -0
- patchfeld/widgets/agent_transcript.py +85 -0
- patchfeld/widgets/change_cwd_screen.py +39 -0
- patchfeld/widgets/chrome.py +210 -0
- patchfeld/widgets/diff_viewer.py +52 -0
- patchfeld/widgets/file_editor.py +258 -0
- patchfeld/widgets/file_tree.py +33 -0
- patchfeld/widgets/file_viewer.py +77 -0
- patchfeld/widgets/history_screen.py +58 -0
- patchfeld/widgets/layout_switcher.py +126 -0
- patchfeld/widgets/log_tail.py +113 -0
- patchfeld/widgets/markdown.py +65 -0
- patchfeld/widgets/new_tab_screen.py +31 -0
- patchfeld/widgets/notebook.py +45 -0
- patchfeld/widgets/orchestrator_chat.py +73 -0
- patchfeld/widgets/permission_modal.py +185 -0
- patchfeld/widgets/permission_request_bar.py +90 -0
- patchfeld/widgets/resume_screen.py +179 -0
- patchfeld/widgets/rich_transcript.py +606 -0
- patchfeld/widgets/system_usage.py +244 -0
- patchfeld/widgets/terminal.py +251 -0
- patchfeld/widgets/theme_switcher.py +63 -0
- patchfeld/widgets/transcript_screen.py +39 -0
- patchfeld/workspace/__init__.py +3 -0
- patchfeld/workspace/spec.py +72 -0
- patchfeld-0.2.0.dist-info/METADATA +584 -0
- patchfeld-0.2.0.dist-info/RECORD +81 -0
- patchfeld-0.2.0.dist-info/WHEEL +4 -0
- patchfeld-0.2.0.dist-info/entry_points.txt +3 -0
- patchfeld-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import dataclasses
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from claude_agent_sdk import (
|
|
9
|
+
CanUseTool,
|
|
10
|
+
ClaudeAgentOptions,
|
|
11
|
+
PermissionResultAllow,
|
|
12
|
+
PermissionResultDeny,
|
|
13
|
+
ToolPermissionContext,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from patchfeld.agents.child_tools import build_child_mcp_server
|
|
17
|
+
from patchfeld.agents.permission_grants import PermissionGrants
|
|
18
|
+
from patchfeld.agents.permission_inbox import PermissionInbox
|
|
19
|
+
from patchfeld.agents.request_inbox import RequestInbox
|
|
20
|
+
from patchfeld.agents.sdk_adapter import SDKAdapter
|
|
21
|
+
from patchfeld.agents.session import AgentSession
|
|
22
|
+
from patchfeld.agents.state import AgentInfo, AgentState
|
|
23
|
+
from patchfeld.events import (
|
|
24
|
+
AgentArchiveChanged,
|
|
25
|
+
AgentSpawned,
|
|
26
|
+
AgentStateChanged,
|
|
27
|
+
DirectMessageToAgent,
|
|
28
|
+
EventBus,
|
|
29
|
+
PermissionRequested,
|
|
30
|
+
PermissionResolved,
|
|
31
|
+
)
|
|
32
|
+
from patchfeld.persistence.agents_index import AgentsIndex
|
|
33
|
+
from patchfeld.persistence.transcript_store import AgentTranscript, TranscriptEntry
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AgentManager:
|
|
37
|
+
"""Owns child AgentSessions: spawn / list / read transcript / interrupt / kill."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
cwd: Path,
|
|
43
|
+
bus: EventBus,
|
|
44
|
+
adapter_factory: Callable[[], SDKAdapter],
|
|
45
|
+
permission_grants: PermissionGrants | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
self._cwd = cwd
|
|
48
|
+
self._bus = bus
|
|
49
|
+
self._adapter_factory = adapter_factory
|
|
50
|
+
self._grants = permission_grants
|
|
51
|
+
self._sessions: dict[str, AgentSession] = {}
|
|
52
|
+
self._inboxes: dict[str, RequestInbox] = {}
|
|
53
|
+
self._perm_inboxes: dict[str, PermissionInbox] = {}
|
|
54
|
+
self._index = AgentsIndex(cwd=cwd)
|
|
55
|
+
# Any agent persisted as still-running belongs to a previous (dead)
|
|
56
|
+
# process. Flip those rows to ERROR so the AgentTable seed doesn't
|
|
57
|
+
# show ghosts as live.
|
|
58
|
+
self._index.reconcile_orphans()
|
|
59
|
+
self._unsub_state = bus.subscribe(AgentStateChanged, self._on_state_changed)
|
|
60
|
+
self._unsub_direct = bus.subscribe(DirectMessageToAgent, self._on_direct_message)
|
|
61
|
+
|
|
62
|
+
async def spawn(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
name: str,
|
|
66
|
+
prompt: str,
|
|
67
|
+
cwd: str | None = None,
|
|
68
|
+
allowed_tools: list[str] | None = None,
|
|
69
|
+
disallowed_tools: list[str] | None = None,
|
|
70
|
+
model: str | None = None,
|
|
71
|
+
system_prompt: str | None = None,
|
|
72
|
+
) -> str:
|
|
73
|
+
agent_id = uuid.uuid4().hex[:12]
|
|
74
|
+
now = time.time()
|
|
75
|
+
# Snapshot the JSON-serializable subset of options needed to rebuild
|
|
76
|
+
# ClaudeAgentOptions on resume. mcp_servers can't be persisted (it
|
|
77
|
+
# contains live server objects); we rebuild it from agent_id+bus+inbox
|
|
78
|
+
# in _build_options instead.
|
|
79
|
+
spawn_options = {
|
|
80
|
+
"cwd": cwd or str(self._cwd),
|
|
81
|
+
"allowed_tools": allowed_tools,
|
|
82
|
+
"disallowed_tools": disallowed_tools,
|
|
83
|
+
"model": model,
|
|
84
|
+
"system_prompt": system_prompt,
|
|
85
|
+
}
|
|
86
|
+
info = AgentInfo(
|
|
87
|
+
id=agent_id,
|
|
88
|
+
name=name,
|
|
89
|
+
cwd=cwd or str(self._cwd),
|
|
90
|
+
started_at=now,
|
|
91
|
+
spawn_options=spawn_options,
|
|
92
|
+
)
|
|
93
|
+
session = self._build_session(info)
|
|
94
|
+
self._index.upsert(info)
|
|
95
|
+
self._bus.publish(AgentSpawned(info=info))
|
|
96
|
+
|
|
97
|
+
await session.start(options=self._build_options(info))
|
|
98
|
+
await session.send(prompt)
|
|
99
|
+
return agent_id
|
|
100
|
+
|
|
101
|
+
def _build_session(self, info: AgentInfo) -> AgentSession:
|
|
102
|
+
adapter = self._adapter_factory()
|
|
103
|
+
transcript = AgentTranscript(cwd=self._cwd, agent_id=info.id)
|
|
104
|
+
session = AgentSession(
|
|
105
|
+
info=info,
|
|
106
|
+
adapter=adapter,
|
|
107
|
+
transcript=transcript,
|
|
108
|
+
bus=self._bus,
|
|
109
|
+
on_session_id=lambda sid, _id=info.id: self._on_session_id(_id, sid),
|
|
110
|
+
)
|
|
111
|
+
# Inbox lifecycle drives session state: count > 0 → WAITING,
|
|
112
|
+
# count == 0 → restore prior state. _mark_unwaiting is defensively
|
|
113
|
+
# a no-op outside WAITING, so a `kill()` mid-wait that drains the
|
|
114
|
+
# future after the session is gone is safe.
|
|
115
|
+
def _on_pending_changed(count: int, _session=session) -> None:
|
|
116
|
+
if count > 0:
|
|
117
|
+
_session._mark_waiting()
|
|
118
|
+
else:
|
|
119
|
+
_session._mark_unwaiting()
|
|
120
|
+
|
|
121
|
+
self._inboxes[info.id] = RequestInbox(
|
|
122
|
+
on_pending_changed=_on_pending_changed,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def _on_perm_changed(count: int, _session=session) -> None:
|
|
126
|
+
if count > 0:
|
|
127
|
+
_session._mark_awaiting_permission()
|
|
128
|
+
else:
|
|
129
|
+
_session._mark_done_permission()
|
|
130
|
+
|
|
131
|
+
self._perm_inboxes[info.id] = PermissionInbox(
|
|
132
|
+
on_pending_changed=_on_perm_changed,
|
|
133
|
+
)
|
|
134
|
+
self._sessions[info.id] = session
|
|
135
|
+
return session
|
|
136
|
+
|
|
137
|
+
def _build_options(
|
|
138
|
+
self, info: AgentInfo, *, resume_session_id: str | None = None,
|
|
139
|
+
) -> ClaudeAgentOptions:
|
|
140
|
+
# Permission posture: presence of self._grants is the gate.
|
|
141
|
+
# - None → permission_mode="bypassPermissions" (preserves the
|
|
142
|
+
# original behavior; equivalent to launching with
|
|
143
|
+
# --bypass-permissions).
|
|
144
|
+
# - obj → drop bypass, attach can_use_tool that consults the
|
|
145
|
+
# grants store first and falls back to the modal flow.
|
|
146
|
+
child_mcp = build_child_mcp_server(
|
|
147
|
+
agent_id=info.id, bus=self._bus, inbox=self._inboxes[info.id],
|
|
148
|
+
)
|
|
149
|
+
opts = info.spawn_options or {}
|
|
150
|
+
kwargs: dict = {
|
|
151
|
+
"cwd": opts.get("cwd") or info.cwd,
|
|
152
|
+
"mcp_servers": {"patchfeld_child": child_mcp},
|
|
153
|
+
}
|
|
154
|
+
if self._grants is None:
|
|
155
|
+
kwargs["permission_mode"] = "bypassPermissions"
|
|
156
|
+
else:
|
|
157
|
+
kwargs["can_use_tool"] = self._make_can_use_tool(
|
|
158
|
+
agent_id=info.id, agent_name=info.name,
|
|
159
|
+
)
|
|
160
|
+
if opts.get("allowed_tools") is not None:
|
|
161
|
+
kwargs["allowed_tools"] = opts["allowed_tools"]
|
|
162
|
+
if opts.get("disallowed_tools") is not None:
|
|
163
|
+
kwargs["disallowed_tools"] = opts["disallowed_tools"]
|
|
164
|
+
if opts.get("model") is not None:
|
|
165
|
+
kwargs["model"] = opts["model"]
|
|
166
|
+
if opts.get("system_prompt") is not None:
|
|
167
|
+
kwargs["system_prompt"] = opts["system_prompt"]
|
|
168
|
+
if resume_session_id is not None:
|
|
169
|
+
kwargs["resume"] = resume_session_id
|
|
170
|
+
return ClaudeAgentOptions(**kwargs)
|
|
171
|
+
|
|
172
|
+
def _make_can_use_tool(self, *, agent_id: str, agent_name: str) -> CanUseTool:
|
|
173
|
+
bus = self._bus
|
|
174
|
+
grants = self._grants
|
|
175
|
+
get_perm_inbox = self._perm_inboxes.get
|
|
176
|
+
# 30 minutes — long enough to step away briefly, short enough that a
|
|
177
|
+
# forgotten prompt doesn't strand the session forever.
|
|
178
|
+
TIMEOUT_S = 30 * 60
|
|
179
|
+
|
|
180
|
+
async def callback(
|
|
181
|
+
tool_name: str,
|
|
182
|
+
tool_input: dict,
|
|
183
|
+
ctx: ToolPermissionContext,
|
|
184
|
+
):
|
|
185
|
+
assert grants is not None # invariant when callback is wired
|
|
186
|
+
decision = grants.lookup(agent_name=agent_name, tool_name=tool_name)
|
|
187
|
+
if decision == "allow":
|
|
188
|
+
return PermissionResultAllow()
|
|
189
|
+
if decision == "deny":
|
|
190
|
+
return PermissionResultDeny(message="denied by saved rule")
|
|
191
|
+
|
|
192
|
+
inbox = get_perm_inbox(agent_id)
|
|
193
|
+
if inbox is None:
|
|
194
|
+
return PermissionResultDeny(message="agent gone", interrupt=True)
|
|
195
|
+
request_id = inbox.register(
|
|
196
|
+
tool_name=tool_name, tool_input=tool_input,
|
|
197
|
+
title=getattr(ctx, "title", None),
|
|
198
|
+
description=getattr(ctx, "description", None),
|
|
199
|
+
)
|
|
200
|
+
bus.publish(PermissionRequested(
|
|
201
|
+
agent_id=agent_id, agent_name=agent_name,
|
|
202
|
+
request_id=request_id, tool_name=tool_name,
|
|
203
|
+
tool_input=tool_input,
|
|
204
|
+
title=getattr(ctx, "title", None),
|
|
205
|
+
description=getattr(ctx, "description", None),
|
|
206
|
+
))
|
|
207
|
+
try:
|
|
208
|
+
result = await inbox.wait(request_id, timeout_s=TIMEOUT_S)
|
|
209
|
+
except asyncio.CancelledError:
|
|
210
|
+
task = asyncio.current_task()
|
|
211
|
+
if task is not None and task.cancelling() > 0:
|
|
212
|
+
raise # real task cancellation — must propagate
|
|
213
|
+
bus.publish(PermissionResolved(
|
|
214
|
+
agent_id=agent_id, request_id=request_id,
|
|
215
|
+
behavior="cancelled",
|
|
216
|
+
))
|
|
217
|
+
return PermissionResultDeny(message="cancelled", interrupt=True)
|
|
218
|
+
except asyncio.TimeoutError:
|
|
219
|
+
bus.publish(PermissionResolved(
|
|
220
|
+
agent_id=agent_id, request_id=request_id, behavior="deny",
|
|
221
|
+
))
|
|
222
|
+
return PermissionResultDeny(message="timed out")
|
|
223
|
+
bus.publish(PermissionResolved(
|
|
224
|
+
agent_id=agent_id, request_id=request_id,
|
|
225
|
+
behavior="allow" if isinstance(result, PermissionResultAllow) else "deny",
|
|
226
|
+
))
|
|
227
|
+
return result
|
|
228
|
+
|
|
229
|
+
return callback
|
|
230
|
+
|
|
231
|
+
def _on_session_id(self, agent_id: str, session_id: str) -> None:
|
|
232
|
+
# The first ResultMessage carries the SDK session id. Capture it on
|
|
233
|
+
# the persisted info so a fresh process can pass it back as resume=
|
|
234
|
+
# to keep the conversation alive.
|
|
235
|
+
session = self._sessions.get(agent_id)
|
|
236
|
+
if session is None:
|
|
237
|
+
return
|
|
238
|
+
session.info.session_id = session_id
|
|
239
|
+
self._index.upsert(session.info)
|
|
240
|
+
|
|
241
|
+
async def resume(self, agent_id: str) -> AgentSession | None:
|
|
242
|
+
# If the agent already has a live session, just hand it back.
|
|
243
|
+
existing = self._sessions.get(agent_id)
|
|
244
|
+
if existing is not None:
|
|
245
|
+
return existing
|
|
246
|
+
# Find the persisted record. If it's missing, or it predates the
|
|
247
|
+
# resume feature (no session_id / no spawn_options), we can't bring
|
|
248
|
+
# it back to life — caller must spawn fresh.
|
|
249
|
+
for info in self._index.load():
|
|
250
|
+
if info.id == agent_id:
|
|
251
|
+
target = info
|
|
252
|
+
break
|
|
253
|
+
else:
|
|
254
|
+
return None
|
|
255
|
+
if target.session_id is None or target.spawn_options is None:
|
|
256
|
+
return None
|
|
257
|
+
# Resurrect: fresh adapter, AgentSession, and SDK process pointed at
|
|
258
|
+
# the same session_id so the conversation continues.
|
|
259
|
+
target.state = AgentState.IDLE
|
|
260
|
+
target.ended_at = None
|
|
261
|
+
session = self._build_session(target)
|
|
262
|
+
self._index.upsert(target)
|
|
263
|
+
self._bus.publish(AgentSpawned(info=target))
|
|
264
|
+
await session.start(
|
|
265
|
+
options=self._build_options(target, resume_session_id=target.session_id),
|
|
266
|
+
)
|
|
267
|
+
return session
|
|
268
|
+
|
|
269
|
+
def list_infos(self) -> list[AgentInfo]:
|
|
270
|
+
return [s.info for s in self._sessions.values()]
|
|
271
|
+
|
|
272
|
+
def get_session(self, agent_id: str) -> AgentSession | None:
|
|
273
|
+
return self._sessions.get(agent_id)
|
|
274
|
+
|
|
275
|
+
def read_transcript(self, agent_id: str) -> list[TranscriptEntry]:
|
|
276
|
+
path_transcript = AgentTranscript(cwd=self._cwd, agent_id=agent_id)
|
|
277
|
+
return path_transcript.read_all()
|
|
278
|
+
|
|
279
|
+
async def interrupt(self, agent_id: str) -> None:
|
|
280
|
+
session = self._sessions.get(agent_id)
|
|
281
|
+
if session is not None:
|
|
282
|
+
await session.interrupt()
|
|
283
|
+
|
|
284
|
+
async def kill(self, agent_id: str) -> None:
|
|
285
|
+
session = self._sessions.pop(agent_id, None)
|
|
286
|
+
self._inboxes.pop(agent_id, None)
|
|
287
|
+
perm_inbox = self._perm_inboxes.pop(agent_id, None)
|
|
288
|
+
if perm_inbox is not None:
|
|
289
|
+
perm_inbox.cancel_all()
|
|
290
|
+
if session is not None:
|
|
291
|
+
await session.stop()
|
|
292
|
+
|
|
293
|
+
async def wait_idle(self, agent_id: str) -> None:
|
|
294
|
+
session = self._sessions.get(agent_id)
|
|
295
|
+
if session is not None:
|
|
296
|
+
await session.wait_idle()
|
|
297
|
+
|
|
298
|
+
async def send(self, agent_id: str, text: str) -> None:
|
|
299
|
+
session = self._sessions.get(agent_id)
|
|
300
|
+
if session is None:
|
|
301
|
+
raise KeyError(f"unknown agent_id: {agent_id}")
|
|
302
|
+
await session.send(text)
|
|
303
|
+
|
|
304
|
+
def get_inbox(self, agent_id: str) -> RequestInbox | None:
|
|
305
|
+
return self._inboxes.get(agent_id)
|
|
306
|
+
|
|
307
|
+
def get_permission_inbox(self, agent_id: str) -> PermissionInbox | None:
|
|
308
|
+
return self._perm_inboxes.get(agent_id)
|
|
309
|
+
|
|
310
|
+
def set_archived(self, agent_id: str, *, archived: bool) -> None:
|
|
311
|
+
"""Toggle the archived flag for an agent. Persists to agents.json and
|
|
312
|
+
publishes AgentArchiveChanged so listeners (e.g., AgentTable) can
|
|
313
|
+
refresh. Raises KeyError if `agent_id` is unknown."""
|
|
314
|
+
# The archived flag is metadata, not runtime state — agents from a
|
|
315
|
+
# previous process show up in the table (seeded from agents.json) but
|
|
316
|
+
# have no live session here. Fall back to the persisted record so
|
|
317
|
+
# archive/unarchive works on those rows too.
|
|
318
|
+
session = self._sessions.get(agent_id)
|
|
319
|
+
if session is not None:
|
|
320
|
+
info = session.info
|
|
321
|
+
else:
|
|
322
|
+
info = next(
|
|
323
|
+
(i for i in self._index.load() if i.id == agent_id), None
|
|
324
|
+
)
|
|
325
|
+
if info is None:
|
|
326
|
+
raise KeyError(f"unknown agent_id: {agent_id}")
|
|
327
|
+
if info.archived == archived:
|
|
328
|
+
return
|
|
329
|
+
info.archived = archived
|
|
330
|
+
self._index.upsert(info)
|
|
331
|
+
# Publish a frozen snapshot so subscribers see a stable view.
|
|
332
|
+
self._bus.publish(AgentArchiveChanged(info=dataclasses.replace(info)))
|
|
333
|
+
|
|
334
|
+
async def shutdown(self) -> None:
|
|
335
|
+
for agent_id in list(self._sessions.keys()):
|
|
336
|
+
await self.kill(agent_id)
|
|
337
|
+
self._unsub_state()
|
|
338
|
+
self._unsub_direct()
|
|
339
|
+
|
|
340
|
+
# --- internals --------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
def _on_state_changed(self, event: AgentStateChanged) -> None:
|
|
343
|
+
# Persist updated info on every state change so agents.json reflects reality.
|
|
344
|
+
self._index.upsert(event.info)
|
|
345
|
+
|
|
346
|
+
def _on_direct_message(self, event: DirectMessageToAgent) -> None:
|
|
347
|
+
session = self._sessions.get(event.agent_id)
|
|
348
|
+
if session is not None:
|
|
349
|
+
session.queue_send(event.text)
|
|
350
|
+
return
|
|
351
|
+
# No live session: this is a record from a previous process. Try to
|
|
352
|
+
# resurrect it via SDK resume so the user's message lands on a real
|
|
353
|
+
# conversation. Schedule on the running loop because EventBus
|
|
354
|
+
# handlers must be sync.
|
|
355
|
+
try:
|
|
356
|
+
loop = asyncio.get_running_loop()
|
|
357
|
+
except RuntimeError:
|
|
358
|
+
return # no loop — nothing we can do (e.g. unit-test without loop)
|
|
359
|
+
loop.create_task(self._resume_then_send(event.agent_id, event.text))
|
|
360
|
+
|
|
361
|
+
async def _resume_then_send(self, agent_id: str, text: str) -> None:
|
|
362
|
+
session = await self.resume(agent_id)
|
|
363
|
+
if session is None:
|
|
364
|
+
return # legacy record without session_id/spawn_options
|
|
365
|
+
session.queue_send(text)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Persistent + session-scoped grant rules for tool permissions.
|
|
2
|
+
|
|
3
|
+
DESIGN TRADEOFF (revisit when convenient):
|
|
4
|
+
This module keys persistent grants by ``(agent_name, tool_name)``. The
|
|
5
|
+
agent_name is the literal "orchestrator" for the user's main session and
|
|
6
|
+
the user-supplied name for child agents. Respawning a child of the same
|
|
7
|
+
name reuses prior decisions — convenient in the common case, surprising
|
|
8
|
+
if a name is reused with different intent.
|
|
9
|
+
|
|
10
|
+
A future revision could swap the disk-backed lookup for an in-memory one
|
|
11
|
+
keyed by ``agent_id`` (scoped to one live spawn). The interface here is
|
|
12
|
+
intentionally narrow so the swap touches only this file.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Literal
|
|
19
|
+
|
|
20
|
+
from patchfeld.persistence.atomic import write_json_atomic
|
|
21
|
+
from patchfeld.persistence.paths import project_state_dir
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
Behavior = Literal["allow", "deny"]
|
|
26
|
+
Scope = Literal["persistent", "session"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _grants_path(cwd: Path) -> Path:
|
|
30
|
+
return project_state_dir(cwd) / "permission_grants.json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PermissionGrants:
|
|
34
|
+
"""Disk-backed allow/deny rules keyed by (agent_name, tool_name).
|
|
35
|
+
|
|
36
|
+
`remember(scope="session")` rules live in-memory only and evaporate on
|
|
37
|
+
process exit. `remember(scope="persistent")` rules are serialized to
|
|
38
|
+
`<cwd>/.patchfeld/permission_grants.json`.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, *, cwd: Path) -> None:
|
|
42
|
+
self._cwd = Path(cwd)
|
|
43
|
+
self._disk: dict[tuple[str, str], Behavior] = {}
|
|
44
|
+
self._session: dict[tuple[str, str], Behavior] = {}
|
|
45
|
+
self._load_disk()
|
|
46
|
+
|
|
47
|
+
def lookup(self, *, agent_name: str, tool_name: str) -> Behavior | None:
|
|
48
|
+
# Disk wins over session — disk represents an explicit "always" the
|
|
49
|
+
# user chose earlier, session is "for this run." If both exist, the
|
|
50
|
+
# persistent decision is more authoritative.
|
|
51
|
+
key = (agent_name, tool_name)
|
|
52
|
+
return self._disk.get(key) or self._session.get(key)
|
|
53
|
+
|
|
54
|
+
def remember(
|
|
55
|
+
self,
|
|
56
|
+
*,
|
|
57
|
+
agent_name: str,
|
|
58
|
+
tool_name: str,
|
|
59
|
+
behavior: Behavior,
|
|
60
|
+
scope: Scope = "persistent",
|
|
61
|
+
) -> None:
|
|
62
|
+
key = (agent_name, tool_name)
|
|
63
|
+
if scope == "persistent":
|
|
64
|
+
self._disk[key] = behavior
|
|
65
|
+
self._write_disk()
|
|
66
|
+
else:
|
|
67
|
+
self._session[key] = behavior
|
|
68
|
+
|
|
69
|
+
def clear(self) -> None:
|
|
70
|
+
self._disk.clear()
|
|
71
|
+
self._session.clear()
|
|
72
|
+
try:
|
|
73
|
+
_grants_path(self._cwd).unlink()
|
|
74
|
+
except FileNotFoundError:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
def _load_disk(self) -> None:
|
|
78
|
+
path = _grants_path(self._cwd)
|
|
79
|
+
if not path.exists():
|
|
80
|
+
return
|
|
81
|
+
try:
|
|
82
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
83
|
+
for entry in raw.get("grants", []):
|
|
84
|
+
key = (entry["agent_name"], entry["tool_name"])
|
|
85
|
+
self._disk[key] = entry["behavior"]
|
|
86
|
+
except (json.JSONDecodeError, OSError, KeyError, TypeError):
|
|
87
|
+
log.exception("permission_grants.json unreadable; starting empty")
|
|
88
|
+
self._disk.clear()
|
|
89
|
+
|
|
90
|
+
def _write_disk(self) -> None:
|
|
91
|
+
data = {
|
|
92
|
+
"version": 1,
|
|
93
|
+
"grants": [
|
|
94
|
+
{"agent_name": a, "tool_name": t, "behavior": b}
|
|
95
|
+
for (a, t), b in sorted(self._disk.items())
|
|
96
|
+
],
|
|
97
|
+
}
|
|
98
|
+
write_json_atomic(_grants_path(self._cwd), data)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import uuid
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
from claude_agent_sdk import PermissionResult
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class PendingPermission:
|
|
14
|
+
request_id: str
|
|
15
|
+
tool_name: str
|
|
16
|
+
tool_input: dict
|
|
17
|
+
title: str | None = None
|
|
18
|
+
description: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PermissionInbox:
|
|
22
|
+
"""Per-session registry of pending can_use_tool callbacks.
|
|
23
|
+
|
|
24
|
+
Sibling to RequestInbox: same register/wait/resolve shape, different
|
|
25
|
+
payload (PermissionResult vs str) and different blocker semantics — the
|
|
26
|
+
AgentSession flips into AWAITING_PERMISSION while count > 0.
|
|
27
|
+
|
|
28
|
+
`on_pending_changed`, if provided, is called synchronously after every
|
|
29
|
+
transition that changes the pending count.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
on_pending_changed: Callable[[int], None] | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._records: dict[str, PendingPermission] = {}
|
|
38
|
+
self._futures: dict[str, asyncio.Future] = {}
|
|
39
|
+
self._on_pending_changed = on_pending_changed
|
|
40
|
+
|
|
41
|
+
def register(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
tool_name: str,
|
|
45
|
+
tool_input: dict,
|
|
46
|
+
title: str | None = None,
|
|
47
|
+
description: str | None = None,
|
|
48
|
+
) -> str:
|
|
49
|
+
request_id = uuid.uuid4().hex[:12]
|
|
50
|
+
loop = asyncio.get_running_loop()
|
|
51
|
+
self._futures[request_id] = loop.create_future()
|
|
52
|
+
self._records[request_id] = PendingPermission(
|
|
53
|
+
request_id=request_id, tool_name=tool_name, tool_input=tool_input,
|
|
54
|
+
title=title, description=description,
|
|
55
|
+
)
|
|
56
|
+
self._notify()
|
|
57
|
+
return request_id
|
|
58
|
+
|
|
59
|
+
def resolve(self, request_id: str, result: PermissionResult) -> None:
|
|
60
|
+
future = self._futures.get(request_id)
|
|
61
|
+
if future is not None and not future.done():
|
|
62
|
+
future.set_result(result)
|
|
63
|
+
|
|
64
|
+
async def wait(self, request_id: str, *, timeout_s: float) -> PermissionResult:
|
|
65
|
+
future = self._futures.get(request_id)
|
|
66
|
+
if future is None:
|
|
67
|
+
raise KeyError(f"unknown request_id: {request_id}")
|
|
68
|
+
try:
|
|
69
|
+
return await asyncio.wait_for(future, timeout=timeout_s)
|
|
70
|
+
finally:
|
|
71
|
+
self._futures.pop(request_id, None)
|
|
72
|
+
self._records.pop(request_id, None)
|
|
73
|
+
self._notify()
|
|
74
|
+
|
|
75
|
+
def cancel_all(self) -> None:
|
|
76
|
+
"""Cancel every pending future. Used by AgentManager.kill /
|
|
77
|
+
OrchestratorSession.stop."""
|
|
78
|
+
for fut in self._futures.values():
|
|
79
|
+
if not fut.done():
|
|
80
|
+
fut.cancel()
|
|
81
|
+
|
|
82
|
+
def pending(self) -> list[PendingPermission]:
|
|
83
|
+
return list(self._records.values())
|
|
84
|
+
|
|
85
|
+
def _notify(self) -> None:
|
|
86
|
+
if self._on_pending_changed is None:
|
|
87
|
+
return
|
|
88
|
+
try:
|
|
89
|
+
self._on_pending_changed(len(self._futures))
|
|
90
|
+
except Exception:
|
|
91
|
+
log.exception("PermissionInbox.on_pending_changed handler raised")
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import uuid
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RequestInbox:
|
|
7
|
+
"""Per-agent registry of pending ask_orchestrator requests.
|
|
8
|
+
|
|
9
|
+
Each registered request_id has an asyncio.Future. The agent's tool call
|
|
10
|
+
awaits the future (with a timeout); the orchestrator's reply resolves it.
|
|
11
|
+
|
|
12
|
+
`on_pending_changed`, if provided, is invoked synchronously after every
|
|
13
|
+
transition that changes the pending count — i.e., after `register()` and
|
|
14
|
+
after `wait()` removes a future from the dict. It receives the new
|
|
15
|
+
pending count.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
*,
|
|
21
|
+
on_pending_changed: Callable[[int], None] | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
self._futures: dict[str, asyncio.Future] = {}
|
|
24
|
+
self._on_pending_changed = on_pending_changed
|
|
25
|
+
|
|
26
|
+
def register(self) -> str:
|
|
27
|
+
request_id = uuid.uuid4().hex[:12]
|
|
28
|
+
# get_running_loop() instead of get_event_loop(): the latter is
|
|
29
|
+
# deprecated in Python 3.12+ when called outside a running loop.
|
|
30
|
+
# All callers run inside an event loop (tool handlers are async).
|
|
31
|
+
loop = asyncio.get_running_loop()
|
|
32
|
+
self._futures[request_id] = loop.create_future()
|
|
33
|
+
self._notify()
|
|
34
|
+
return request_id
|
|
35
|
+
|
|
36
|
+
def resolve(self, request_id: str, response: str) -> None:
|
|
37
|
+
future = self._futures.get(request_id)
|
|
38
|
+
if future is not None and not future.done():
|
|
39
|
+
future.set_result(response)
|
|
40
|
+
|
|
41
|
+
async def wait(self, request_id: str, *, timeout_s: float) -> str:
|
|
42
|
+
future = self._futures.get(request_id)
|
|
43
|
+
if future is None:
|
|
44
|
+
raise KeyError(f"unknown request_id: {request_id}")
|
|
45
|
+
try:
|
|
46
|
+
return await asyncio.wait_for(future, timeout=timeout_s)
|
|
47
|
+
finally:
|
|
48
|
+
self._futures.pop(request_id, None)
|
|
49
|
+
self._notify()
|
|
50
|
+
|
|
51
|
+
def pending(self) -> list[str]:
|
|
52
|
+
return [rid for rid, fut in self._futures.items() if not fut.done()]
|
|
53
|
+
|
|
54
|
+
def _notify(self) -> None:
|
|
55
|
+
if self._on_pending_changed is None:
|
|
56
|
+
return
|
|
57
|
+
try:
|
|
58
|
+
self._on_pending_changed(len(self._futures))
|
|
59
|
+
except Exception:
|
|
60
|
+
# The inbox must not poison its own callers if a subscriber
|
|
61
|
+
# explodes; mirror EventBus's swallow-and-log posture.
|
|
62
|
+
import logging
|
|
63
|
+
logging.getLogger(__name__).exception(
|
|
64
|
+
"RequestInbox.on_pending_changed handler raised"
|
|
65
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from typing import AsyncIterator, Protocol
|
|
2
|
+
|
|
3
|
+
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SDKAdapter(Protocol):
|
|
7
|
+
"""Thin wrapping of one Claude Agent SDK session.
|
|
8
|
+
|
|
9
|
+
The interface is the surface our AgentSession uses — one query at a
|
|
10
|
+
time, async stream of messages until the SDK signals completion. The
|
|
11
|
+
real implementation wraps ClaudeSDKClient; tests use FakeSDKAdapter.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
async def start(self, *, options: ClaudeAgentOptions) -> None: ...
|
|
15
|
+
async def query(self, prompt: str) -> None: ...
|
|
16
|
+
def stream(self) -> AsyncIterator[object]:
|
|
17
|
+
"""Yield messages emitted in response to the most recent query.
|
|
18
|
+
Iteration ends when the SDK emits ResultMessage."""
|
|
19
|
+
...
|
|
20
|
+
async def interrupt(self) -> None: ...
|
|
21
|
+
async def stop(self) -> None: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RealSDKAdapter:
|
|
25
|
+
"""Wraps a real ClaudeSDKClient instance."""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._client: ClaudeSDKClient | None = None
|
|
29
|
+
|
|
30
|
+
async def start(self, *, options: ClaudeAgentOptions) -> None:
|
|
31
|
+
self._client = ClaudeSDKClient(options=options)
|
|
32
|
+
await self._client.__aenter__()
|
|
33
|
+
|
|
34
|
+
async def query(self, prompt: str) -> None:
|
|
35
|
+
assert self._client is not None, "start() must be called before query()"
|
|
36
|
+
await self._client.query(prompt)
|
|
37
|
+
|
|
38
|
+
def stream(self) -> AsyncIterator[object]:
|
|
39
|
+
assert self._client is not None, "start() must be called before stream()"
|
|
40
|
+
return self._client.receive_response()
|
|
41
|
+
|
|
42
|
+
async def interrupt(self) -> None:
|
|
43
|
+
if self._client is not None:
|
|
44
|
+
await self._client.interrupt()
|
|
45
|
+
|
|
46
|
+
async def stop(self) -> None:
|
|
47
|
+
if self._client is not None:
|
|
48
|
+
await self._client.__aexit__(None, None, None)
|
|
49
|
+
self._client = None
|