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.
Files changed (81) hide show
  1. patchfeld/__init__.py +1 -0
  2. patchfeld/__main__.py +32 -0
  3. patchfeld/actions.py +34 -0
  4. patchfeld/activity/__init__.py +0 -0
  5. patchfeld/activity/log.py +237 -0
  6. patchfeld/agents/__init__.py +0 -0
  7. patchfeld/agents/child_tools.py +66 -0
  8. patchfeld/agents/fake_sdk_adapter.py +45 -0
  9. patchfeld/agents/manager.py +365 -0
  10. patchfeld/agents/permission_grants.py +98 -0
  11. patchfeld/agents/permission_inbox.py +91 -0
  12. patchfeld/agents/request_inbox.py +65 -0
  13. patchfeld/agents/sdk_adapter.py +49 -0
  14. patchfeld/agents/session.py +250 -0
  15. patchfeld/agents/sort.py +66 -0
  16. patchfeld/agents/state.py +81 -0
  17. patchfeld/app.py +1433 -0
  18. patchfeld/config.py +128 -0
  19. patchfeld/events.py +260 -0
  20. patchfeld/layout/__init__.py +0 -0
  21. patchfeld/layout/custom_widgets.py +82 -0
  22. patchfeld/layout/defaults.py +33 -0
  23. patchfeld/layout/engine.py +241 -0
  24. patchfeld/layout/local_widgets.py +188 -0
  25. patchfeld/layout/registry.py +69 -0
  26. patchfeld/layout/spec.py +104 -0
  27. patchfeld/layout/splitter.py +170 -0
  28. patchfeld/layout/titles.py +70 -0
  29. patchfeld/orchestrator/__init__.py +0 -0
  30. patchfeld/orchestrator/formatting.py +15 -0
  31. patchfeld/orchestrator/session.py +785 -0
  32. patchfeld/orchestrator/tabs_tools.py +149 -0
  33. patchfeld/orchestrator/tools.py +976 -0
  34. patchfeld/persistence/__init__.py +0 -0
  35. patchfeld/persistence/agents_index.py +68 -0
  36. patchfeld/persistence/atomic.py +47 -0
  37. patchfeld/persistence/layout_store.py +25 -0
  38. patchfeld/persistence/layouts_store.py +61 -0
  39. patchfeld/persistence/orchestrator_sessions.py +127 -0
  40. patchfeld/persistence/paths.py +48 -0
  41. patchfeld/persistence/themes_store.py +44 -0
  42. patchfeld/persistence/transcript_store.py +64 -0
  43. patchfeld/persistence/workspace_store.py +25 -0
  44. patchfeld/theme/__init__.py +0 -0
  45. patchfeld/theme/engine.py +75 -0
  46. patchfeld/theme/spec.py +31 -0
  47. patchfeld/widgets/__init__.py +0 -0
  48. patchfeld/widgets/_file_lang.py +36 -0
  49. patchfeld/widgets/_terminal_keys.py +89 -0
  50. patchfeld/widgets/_terminal_render.py +147 -0
  51. patchfeld/widgets/activity_feed.py +365 -0
  52. patchfeld/widgets/agent_table.py +236 -0
  53. patchfeld/widgets/agent_transcript.py +85 -0
  54. patchfeld/widgets/change_cwd_screen.py +39 -0
  55. patchfeld/widgets/chrome.py +210 -0
  56. patchfeld/widgets/diff_viewer.py +52 -0
  57. patchfeld/widgets/file_editor.py +258 -0
  58. patchfeld/widgets/file_tree.py +33 -0
  59. patchfeld/widgets/file_viewer.py +77 -0
  60. patchfeld/widgets/history_screen.py +58 -0
  61. patchfeld/widgets/layout_switcher.py +126 -0
  62. patchfeld/widgets/log_tail.py +113 -0
  63. patchfeld/widgets/markdown.py +65 -0
  64. patchfeld/widgets/new_tab_screen.py +31 -0
  65. patchfeld/widgets/notebook.py +45 -0
  66. patchfeld/widgets/orchestrator_chat.py +73 -0
  67. patchfeld/widgets/permission_modal.py +185 -0
  68. patchfeld/widgets/permission_request_bar.py +90 -0
  69. patchfeld/widgets/resume_screen.py +179 -0
  70. patchfeld/widgets/rich_transcript.py +606 -0
  71. patchfeld/widgets/system_usage.py +244 -0
  72. patchfeld/widgets/terminal.py +251 -0
  73. patchfeld/widgets/theme_switcher.py +63 -0
  74. patchfeld/widgets/transcript_screen.py +39 -0
  75. patchfeld/workspace/__init__.py +3 -0
  76. patchfeld/workspace/spec.py +72 -0
  77. patchfeld-0.2.0.dist-info/METADATA +584 -0
  78. patchfeld-0.2.0.dist-info/RECORD +81 -0
  79. patchfeld-0.2.0.dist-info/WHEEL +4 -0
  80. patchfeld-0.2.0.dist-info/entry_points.txt +3 -0
  81. 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