patchbai 0.1.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.
- patchbai/__init__.py +1 -0
- patchbai/__main__.py +10 -0
- patchbai/actions.py +34 -0
- patchbai/activity/__init__.py +0 -0
- patchbai/activity/log.py +237 -0
- patchbai/agents/__init__.py +0 -0
- patchbai/agents/child_tools.py +66 -0
- patchbai/agents/fake_sdk_adapter.py +45 -0
- patchbai/agents/manager.py +272 -0
- patchbai/agents/request_inbox.py +65 -0
- patchbai/agents/sdk_adapter.py +49 -0
- patchbai/agents/session.py +224 -0
- patchbai/agents/sort.py +66 -0
- patchbai/agents/state.py +80 -0
- patchbai/app.py +1288 -0
- patchbai/config.py +128 -0
- patchbai/events.py +236 -0
- patchbai/layout/__init__.py +0 -0
- patchbai/layout/custom_widgets.py +82 -0
- patchbai/layout/defaults.py +33 -0
- patchbai/layout/engine.py +241 -0
- patchbai/layout/local_widgets.py +188 -0
- patchbai/layout/registry.py +69 -0
- patchbai/layout/spec.py +104 -0
- patchbai/layout/splitter.py +170 -0
- patchbai/layout/titles.py +70 -0
- patchbai/orchestrator/__init__.py +0 -0
- patchbai/orchestrator/formatting.py +15 -0
- patchbai/orchestrator/session.py +644 -0
- patchbai/orchestrator/tabs_tools.py +149 -0
- patchbai/orchestrator/tools.py +976 -0
- patchbai/persistence/__init__.py +0 -0
- patchbai/persistence/agents_index.py +68 -0
- patchbai/persistence/atomic.py +47 -0
- patchbai/persistence/layout_store.py +25 -0
- patchbai/persistence/layouts_store.py +61 -0
- patchbai/persistence/orchestrator_sessions.py +127 -0
- patchbai/persistence/paths.py +48 -0
- patchbai/persistence/themes_store.py +44 -0
- patchbai/persistence/transcript_store.py +64 -0
- patchbai/persistence/workspace_store.py +25 -0
- patchbai/theme/__init__.py +0 -0
- patchbai/theme/engine.py +75 -0
- patchbai/theme/spec.py +31 -0
- patchbai/widgets/__init__.py +0 -0
- patchbai/widgets/_file_lang.py +36 -0
- patchbai/widgets/_terminal_keys.py +89 -0
- patchbai/widgets/_terminal_render.py +147 -0
- patchbai/widgets/activity_feed.py +365 -0
- patchbai/widgets/agent_table.py +235 -0
- patchbai/widgets/agent_transcript.py +58 -0
- patchbai/widgets/change_cwd_screen.py +39 -0
- patchbai/widgets/chrome.py +210 -0
- patchbai/widgets/diff_viewer.py +52 -0
- patchbai/widgets/file_editor.py +258 -0
- patchbai/widgets/file_tree.py +33 -0
- patchbai/widgets/file_viewer.py +77 -0
- patchbai/widgets/history_screen.py +58 -0
- patchbai/widgets/layout_switcher.py +126 -0
- patchbai/widgets/log_tail.py +113 -0
- patchbai/widgets/markdown.py +65 -0
- patchbai/widgets/new_tab_screen.py +31 -0
- patchbai/widgets/notebook.py +45 -0
- patchbai/widgets/orchestrator_chat.py +73 -0
- patchbai/widgets/resume_screen.py +179 -0
- patchbai/widgets/rich_transcript.py +606 -0
- patchbai/widgets/terminal.py +251 -0
- patchbai/widgets/theme_switcher.py +63 -0
- patchbai/widgets/transcript_screen.py +39 -0
- patchbai/workspace/__init__.py +3 -0
- patchbai/workspace/spec.py +72 -0
- patchbai-0.1.0.dist-info/METADATA +573 -0
- patchbai-0.1.0.dist-info/RECORD +76 -0
- patchbai-0.1.0.dist-info/WHEEL +4 -0
- patchbai-0.1.0.dist-info/entry_points.txt +3 -0
- patchbai-0.1.0.dist-info/licenses/LICENSE +21 -0
patchbai/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
patchbai/__main__.py
ADDED
patchbai/actions.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any, Callable
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class ActionSpec:
|
|
7
|
+
name: str
|
|
8
|
+
callable: Callable
|
|
9
|
+
description: str
|
|
10
|
+
args_schema: dict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ActionRegistry:
|
|
14
|
+
"""Enumerable action registry — name → ActionSpec."""
|
|
15
|
+
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self._actions: dict[str, ActionSpec] = {}
|
|
18
|
+
|
|
19
|
+
def register(self, name: str, fn: Callable, *, description: str, args_schema: dict) -> None:
|
|
20
|
+
self._actions[name] = ActionSpec(
|
|
21
|
+
name=name, callable=fn, description=description, args_schema=args_schema,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def get(self, name: str) -> ActionSpec:
|
|
25
|
+
if name not in self._actions:
|
|
26
|
+
raise KeyError(f"unknown action: {name}")
|
|
27
|
+
return self._actions[name]
|
|
28
|
+
|
|
29
|
+
def list(self) -> list[ActionSpec]:
|
|
30
|
+
return sorted(self._actions.values(), key=lambda s: s.name)
|
|
31
|
+
|
|
32
|
+
def invoke(self, name: str, args: dict) -> Any:
|
|
33
|
+
spec = self.get(name)
|
|
34
|
+
return spec.callable(**(args or {}))
|
|
File without changes
|
patchbai/activity/log.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import deque
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ActivityKind:
|
|
9
|
+
"""String constants for entry kinds. Plain class instead of an Enum so
|
|
10
|
+
`entry.kind == ActivityKind.AGENT_SPAWNED` and `entry.kind == "agent.spawned"`
|
|
11
|
+
are both valid — keeps test tables and the mode-filter dict ergonomic."""
|
|
12
|
+
|
|
13
|
+
AGENT_SPAWNED = "agent.spawned"
|
|
14
|
+
AGENT_STATE = "agent.state"
|
|
15
|
+
AGENT_DONE = "agent.done"
|
|
16
|
+
AGENT_MESSAGE = "agent.message"
|
|
17
|
+
AGENT_TOOL = "agent.tool"
|
|
18
|
+
AGENT_ASK = "agent.ask"
|
|
19
|
+
AGENT_NOTIFY = "agent.notify"
|
|
20
|
+
AGENT_ARCHIVE = "agent.archive"
|
|
21
|
+
ORCH_USER = "orch.user"
|
|
22
|
+
ORCH_REPLY = "orch.reply"
|
|
23
|
+
ORCH_SESSION = "orch.session"
|
|
24
|
+
LAYOUT_APPLIED = "layout.applied"
|
|
25
|
+
LAYOUT_FAILED = "layout.failed"
|
|
26
|
+
TAB_ADDED = "tab.added"
|
|
27
|
+
TAB_CLOSED = "tab.closed"
|
|
28
|
+
TAB_SWITCHED = "tab.switched"
|
|
29
|
+
WORKSPACE_CWD = "workspace.cwd"
|
|
30
|
+
FILE_SELECTED = "file.selected"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class ActivityEntry:
|
|
35
|
+
"""One normalized record in the ActivityLog. `kind` is one of the
|
|
36
|
+
ActivityKind dotted-string constants; `raw` is the original event object
|
|
37
|
+
for debugging/forensics."""
|
|
38
|
+
timestamp: datetime
|
|
39
|
+
kind: str
|
|
40
|
+
summary: str
|
|
41
|
+
detail: str | None
|
|
42
|
+
agent_id: str | None
|
|
43
|
+
tab_id: str | None
|
|
44
|
+
raw: object
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
from patchbai.events import ( # noqa: E402 — deferred to avoid circular import
|
|
48
|
+
ActivityLogged, AgentArchiveChanged, AgentMessageAppended,
|
|
49
|
+
AgentNotifiedOrchestrator, AgentRequestedUserInput, AgentSpawned,
|
|
50
|
+
AgentStateChanged, EventBus, FileSelected, LayoutApplied, LayoutFailed,
|
|
51
|
+
OrchestratorReply, OrchestratorSessionSwitched, TabAdded, TabClosed,
|
|
52
|
+
TabSwitched, UserMessageToOrchestrator, WorkspaceCwdChanged,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ActivityLog:
|
|
57
|
+
"""App-singleton capture of curated EventBus events. Stores the last 500
|
|
58
|
+
normalized entries in a deque. Publishes ActivityLogged after every
|
|
59
|
+
append so subscribers can react incrementally without re-walking the
|
|
60
|
+
backlog. Mode filtering is the consumer's responsibility — the log
|
|
61
|
+
captures the union."""
|
|
62
|
+
|
|
63
|
+
BUFFER_SIZE = 500
|
|
64
|
+
|
|
65
|
+
def __init__(self, bus: EventBus) -> None:
|
|
66
|
+
self._bus = bus
|
|
67
|
+
self._entries: deque[ActivityEntry] = deque(maxlen=self.BUFFER_SIZE)
|
|
68
|
+
self._wire_subscriptions(bus)
|
|
69
|
+
|
|
70
|
+
def entries(self) -> tuple[ActivityEntry, ...]:
|
|
71
|
+
"""Snapshot of current entries, oldest first."""
|
|
72
|
+
return tuple(self._entries)
|
|
73
|
+
|
|
74
|
+
# --- subscriptions -----------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def _wire_subscriptions(self, bus: EventBus) -> None:
|
|
77
|
+
bus.subscribe(AgentSpawned, self._on_agent_spawned)
|
|
78
|
+
bus.subscribe(AgentStateChanged, self._on_agent_state)
|
|
79
|
+
bus.subscribe(AgentMessageAppended, self._on_agent_message)
|
|
80
|
+
bus.subscribe(AgentRequestedUserInput, self._on_agent_ask)
|
|
81
|
+
bus.subscribe(AgentNotifiedOrchestrator, self._on_agent_notify)
|
|
82
|
+
bus.subscribe(AgentArchiveChanged, self._on_agent_archive)
|
|
83
|
+
bus.subscribe(UserMessageToOrchestrator, self._on_orch_user)
|
|
84
|
+
bus.subscribe(OrchestratorReply, self._on_orch_reply)
|
|
85
|
+
bus.subscribe(OrchestratorSessionSwitched, self._on_orch_session)
|
|
86
|
+
bus.subscribe(LayoutApplied, self._on_layout_applied)
|
|
87
|
+
bus.subscribe(LayoutFailed, self._on_layout_failed)
|
|
88
|
+
bus.subscribe(TabAdded, self._on_tab_added)
|
|
89
|
+
bus.subscribe(TabClosed, self._on_tab_closed)
|
|
90
|
+
bus.subscribe(TabSwitched, self._on_tab_switched)
|
|
91
|
+
bus.subscribe(WorkspaceCwdChanged, self._on_cwd_changed)
|
|
92
|
+
bus.subscribe(FileSelected, self._on_file_selected)
|
|
93
|
+
|
|
94
|
+
def _on_agent_spawned(self, event: AgentSpawned) -> None:
|
|
95
|
+
info = event.info
|
|
96
|
+
self._append(
|
|
97
|
+
kind=ActivityKind.AGENT_SPAWNED,
|
|
98
|
+
summary=f"{info.name} spawned",
|
|
99
|
+
detail=f"cwd: {info.cwd}",
|
|
100
|
+
agent_id=info.id,
|
|
101
|
+
tab_id=None,
|
|
102
|
+
raw=event,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def _on_agent_state(self, event: AgentStateChanged) -> None:
|
|
106
|
+
info = event.info
|
|
107
|
+
kind = ActivityKind.AGENT_DONE if info.state.is_terminal else ActivityKind.AGENT_STATE
|
|
108
|
+
summary = f"{info.name}: {event.old_state.value} → {info.state.value}"
|
|
109
|
+
self._append(
|
|
110
|
+
kind=kind, summary=summary, detail=None,
|
|
111
|
+
agent_id=info.id, tab_id=None, raw=event,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def _on_agent_message(self, event: AgentMessageAppended) -> None:
|
|
115
|
+
if event.role in ("user", "assistant"):
|
|
116
|
+
kind = ActivityKind.AGENT_MESSAGE
|
|
117
|
+
detail = event.text
|
|
118
|
+
elif event.role in ("tool_use", "tool_result"):
|
|
119
|
+
kind = ActivityKind.AGENT_TOOL
|
|
120
|
+
detail = event.tool_name or event.text
|
|
121
|
+
else:
|
|
122
|
+
return # thinking/system are not surfaced in the feed
|
|
123
|
+
self._append(
|
|
124
|
+
kind=kind,
|
|
125
|
+
summary=event.agent_id,
|
|
126
|
+
detail=detail,
|
|
127
|
+
agent_id=event.agent_id,
|
|
128
|
+
tab_id=None,
|
|
129
|
+
raw=event,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def _on_agent_ask(self, event: AgentRequestedUserInput) -> None:
|
|
133
|
+
self._append(
|
|
134
|
+
kind=ActivityKind.AGENT_ASK,
|
|
135
|
+
summary=event.agent_id,
|
|
136
|
+
detail=event.question,
|
|
137
|
+
agent_id=event.agent_id,
|
|
138
|
+
tab_id=None,
|
|
139
|
+
raw=event,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def _on_agent_notify(self, event: AgentNotifiedOrchestrator) -> None:
|
|
143
|
+
self._append(
|
|
144
|
+
kind=ActivityKind.AGENT_NOTIFY,
|
|
145
|
+
summary=event.agent_id,
|
|
146
|
+
detail=event.message,
|
|
147
|
+
agent_id=event.agent_id,
|
|
148
|
+
tab_id=None,
|
|
149
|
+
raw=event,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def _on_agent_archive(self, event: AgentArchiveChanged) -> None:
|
|
153
|
+
info = event.info
|
|
154
|
+
self._append(
|
|
155
|
+
kind=ActivityKind.AGENT_ARCHIVE,
|
|
156
|
+
summary=f"{info.name} {'archived' if info.archived else 'unarchived'}",
|
|
157
|
+
detail=None,
|
|
158
|
+
agent_id=info.id,
|
|
159
|
+
tab_id=None,
|
|
160
|
+
raw=event,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def _on_orch_user(self, event: UserMessageToOrchestrator) -> None:
|
|
164
|
+
self._append(
|
|
165
|
+
kind=ActivityKind.ORCH_USER, summary="user → orchestrator",
|
|
166
|
+
detail=event.text, agent_id=None, tab_id=None, raw=event,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def _on_orch_reply(self, event: OrchestratorReply) -> None:
|
|
170
|
+
self._append(
|
|
171
|
+
kind=ActivityKind.ORCH_REPLY, summary="orchestrator → user",
|
|
172
|
+
detail=event.text, agent_id=None, tab_id=None, raw=event,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def _on_orch_session(self, event: OrchestratorSessionSwitched) -> None:
|
|
176
|
+
self._append(
|
|
177
|
+
kind=ActivityKind.ORCH_SESSION,
|
|
178
|
+
summary=f"session → {event.session_id[:8]}",
|
|
179
|
+
detail=event.transcript_path, agent_id=None, tab_id=None, raw=event,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def _on_layout_applied(self, event: LayoutApplied) -> None:
|
|
183
|
+
self._append(
|
|
184
|
+
kind=ActivityKind.LAYOUT_APPLIED,
|
|
185
|
+
summary=event.layout_name or "(unnamed)",
|
|
186
|
+
detail=None, agent_id=None, tab_id=event.tab_id, raw=event,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def _on_layout_failed(self, event: LayoutFailed) -> None:
|
|
190
|
+
self._append(
|
|
191
|
+
kind=ActivityKind.LAYOUT_FAILED, summary="layout failed",
|
|
192
|
+
detail=event.error, agent_id=None, tab_id=event.tab_id, raw=event,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def _on_tab_added(self, event: TabAdded) -> None:
|
|
196
|
+
self._append(
|
|
197
|
+
kind=ActivityKind.TAB_ADDED, summary=event.title, detail=None,
|
|
198
|
+
agent_id=None, tab_id=event.tab_id, raw=event,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def _on_tab_closed(self, event: TabClosed) -> None:
|
|
202
|
+
self._append(
|
|
203
|
+
kind=ActivityKind.TAB_CLOSED, summary=event.tab_id, detail=None,
|
|
204
|
+
agent_id=None, tab_id=event.tab_id, raw=event,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def _on_tab_switched(self, event: TabSwitched) -> None:
|
|
208
|
+
self._append(
|
|
209
|
+
kind=ActivityKind.TAB_SWITCHED, summary=event.title, detail=None,
|
|
210
|
+
agent_id=None, tab_id=event.tab_id, raw=event,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def _on_cwd_changed(self, event: WorkspaceCwdChanged) -> None:
|
|
214
|
+
self._append(
|
|
215
|
+
kind=ActivityKind.WORKSPACE_CWD, summary=event.cwd, detail=None,
|
|
216
|
+
agent_id=None, tab_id=None, raw=event,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def _on_file_selected(self, event: FileSelected) -> None:
|
|
220
|
+
self._append(
|
|
221
|
+
kind=ActivityKind.FILE_SELECTED, summary=event.path, detail=None,
|
|
222
|
+
agent_id=None, tab_id=None, raw=event,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# --- append ------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
def _append(
|
|
228
|
+
self, *, kind: str, summary: str, detail: str | None,
|
|
229
|
+
agent_id: str | None, tab_id: str | None, raw: object,
|
|
230
|
+
) -> None:
|
|
231
|
+
entry = ActivityEntry(
|
|
232
|
+
timestamp=datetime.now(),
|
|
233
|
+
kind=kind, summary=summary, detail=detail,
|
|
234
|
+
agent_id=agent_id, tab_id=tab_id, raw=raw,
|
|
235
|
+
)
|
|
236
|
+
self._entries.append(entry)
|
|
237
|
+
self._bus.publish(ActivityLogged(entry=entry))
|
|
File without changes
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from claude_agent_sdk import create_sdk_mcp_server, tool
|
|
4
|
+
|
|
5
|
+
from patchbai.agents.request_inbox import RequestInbox
|
|
6
|
+
from patchbai.events import (
|
|
7
|
+
AgentNotifiedOrchestrator,
|
|
8
|
+
AgentRequestedUserInput,
|
|
9
|
+
EventBus,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_child_tools(*, agent_id: str, bus: EventBus, inbox: RequestInbox):
|
|
14
|
+
"""Return (notify_handler, ask_handler) — bare async callables for unit tests."""
|
|
15
|
+
|
|
16
|
+
async def notify_orchestrator(args: dict) -> dict:
|
|
17
|
+
message = args["message"]
|
|
18
|
+
bus.publish(AgentNotifiedOrchestrator(agent_id=agent_id, message=message))
|
|
19
|
+
return {"content": [{"type": "text", "text": "Notification delivered."}]}
|
|
20
|
+
|
|
21
|
+
async def ask_orchestrator(args: dict) -> dict:
|
|
22
|
+
question = args["question"]
|
|
23
|
+
timeout_s = float(args.get("timeout_s", 300))
|
|
24
|
+
request_id = inbox.register()
|
|
25
|
+
bus.publish(
|
|
26
|
+
AgentRequestedUserInput(
|
|
27
|
+
agent_id=agent_id, question=question, request_id=request_id
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
try:
|
|
31
|
+
response = await inbox.wait(request_id, timeout_s=timeout_s)
|
|
32
|
+
return {"content": [{"type": "text", "text": response}]}
|
|
33
|
+
except asyncio.TimeoutError:
|
|
34
|
+
return {
|
|
35
|
+
"content": [
|
|
36
|
+
{
|
|
37
|
+
"type": "text",
|
|
38
|
+
"text": (
|
|
39
|
+
f"ask_orchestrator timed out after {timeout_s}s "
|
|
40
|
+
"with no response."
|
|
41
|
+
),
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return notify_orchestrator, ask_orchestrator
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_child_mcp_server(*, agent_id: str, bus: EventBus, inbox: RequestInbox):
|
|
50
|
+
notify_h, ask_h = build_child_tools(agent_id=agent_id, bus=bus, inbox=inbox)
|
|
51
|
+
notify = tool(
|
|
52
|
+
"notify_orchestrator",
|
|
53
|
+
"Send a fire-and-forget notification to the orchestrator.",
|
|
54
|
+
{"message": str},
|
|
55
|
+
)(notify_h)
|
|
56
|
+
ask = tool(
|
|
57
|
+
"ask_orchestrator",
|
|
58
|
+
(
|
|
59
|
+
"Ask the orchestrator a question and block until they respond. "
|
|
60
|
+
"Optional timeout_s defaults to 300 seconds."
|
|
61
|
+
),
|
|
62
|
+
{"question": str}, # timeout_s is optional, not in schema
|
|
63
|
+
)(ask_h)
|
|
64
|
+
return create_sdk_mcp_server(
|
|
65
|
+
name="patchbai_child", version="1.0.0", tools=[notify, ask]
|
|
66
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from typing import AsyncIterator
|
|
2
|
+
|
|
3
|
+
from claude_agent_sdk import ClaudeAgentOptions
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FakeSDKAdapter:
|
|
7
|
+
"""Replays canned message streams. One script per expected query call."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, scripts: list[list[object]]) -> None:
|
|
10
|
+
self._scripts = scripts
|
|
11
|
+
self._next_query_index = 0
|
|
12
|
+
self._pending: list[object] = []
|
|
13
|
+
self._started = False
|
|
14
|
+
|
|
15
|
+
async def start(self, *, options: ClaudeAgentOptions) -> None:
|
|
16
|
+
self._started = True
|
|
17
|
+
|
|
18
|
+
async def query(self, prompt: str) -> None:
|
|
19
|
+
assert self._started, "start() must be called before query()"
|
|
20
|
+
if self._next_query_index >= len(self._scripts):
|
|
21
|
+
raise IndexError(
|
|
22
|
+
f"FakeSDKAdapter has no script for query #{self._next_query_index} "
|
|
23
|
+
f"(only {len(self._scripts)} provided)"
|
|
24
|
+
)
|
|
25
|
+
self._pending = list(self._scripts[self._next_query_index])
|
|
26
|
+
self._next_query_index += 1
|
|
27
|
+
|
|
28
|
+
def stream(self) -> AsyncIterator[object]:
|
|
29
|
+
# Snapshot _pending into a local so calling stream() then mutating
|
|
30
|
+
# _pending mid-iteration doesn't cause skipping.
|
|
31
|
+
msgs = self._pending
|
|
32
|
+
self._pending = []
|
|
33
|
+
|
|
34
|
+
async def _agen() -> AsyncIterator[object]:
|
|
35
|
+
for m in msgs:
|
|
36
|
+
yield m
|
|
37
|
+
|
|
38
|
+
return _agen()
|
|
39
|
+
|
|
40
|
+
async def interrupt(self) -> None:
|
|
41
|
+
# No-op for the fake.
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
async def stop(self) -> None:
|
|
45
|
+
self._started = False
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import time
|
|
3
|
+
import uuid
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
from claude_agent_sdk import ClaudeAgentOptions
|
|
8
|
+
|
|
9
|
+
from patchbai.agents.child_tools import build_child_mcp_server
|
|
10
|
+
from patchbai.agents.request_inbox import RequestInbox
|
|
11
|
+
from patchbai.agents.sdk_adapter import SDKAdapter
|
|
12
|
+
from patchbai.agents.session import AgentSession
|
|
13
|
+
from patchbai.agents.state import AgentInfo, AgentState
|
|
14
|
+
from patchbai.events import (
|
|
15
|
+
AgentArchiveChanged,
|
|
16
|
+
AgentSpawned,
|
|
17
|
+
AgentStateChanged,
|
|
18
|
+
DirectMessageToAgent,
|
|
19
|
+
EventBus,
|
|
20
|
+
)
|
|
21
|
+
from patchbai.persistence.agents_index import AgentsIndex
|
|
22
|
+
from patchbai.persistence.transcript_store import AgentTranscript, TranscriptEntry
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AgentManager:
|
|
26
|
+
"""Owns child AgentSessions: spawn / list / read transcript / interrupt / kill."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
cwd: Path,
|
|
32
|
+
bus: EventBus,
|
|
33
|
+
adapter_factory: Callable[[], SDKAdapter],
|
|
34
|
+
) -> None:
|
|
35
|
+
self._cwd = cwd
|
|
36
|
+
self._bus = bus
|
|
37
|
+
self._adapter_factory = adapter_factory
|
|
38
|
+
self._sessions: dict[str, AgentSession] = {}
|
|
39
|
+
self._inboxes: dict[str, RequestInbox] = {}
|
|
40
|
+
self._index = AgentsIndex(cwd=cwd)
|
|
41
|
+
# Any agent persisted as still-running belongs to a previous (dead)
|
|
42
|
+
# process. Flip those rows to ERROR so the AgentTable seed doesn't
|
|
43
|
+
# show ghosts as live.
|
|
44
|
+
self._index.reconcile_orphans()
|
|
45
|
+
self._unsub_state = bus.subscribe(AgentStateChanged, self._on_state_changed)
|
|
46
|
+
self._unsub_direct = bus.subscribe(DirectMessageToAgent, self._on_direct_message)
|
|
47
|
+
|
|
48
|
+
async def spawn(
|
|
49
|
+
self,
|
|
50
|
+
*,
|
|
51
|
+
name: str,
|
|
52
|
+
prompt: str,
|
|
53
|
+
cwd: str | None = None,
|
|
54
|
+
allowed_tools: list[str] | None = None,
|
|
55
|
+
disallowed_tools: list[str] | None = None,
|
|
56
|
+
model: str | None = None,
|
|
57
|
+
system_prompt: str | None = None,
|
|
58
|
+
) -> str:
|
|
59
|
+
agent_id = uuid.uuid4().hex[:12]
|
|
60
|
+
now = time.time()
|
|
61
|
+
# Snapshot the JSON-serializable subset of options needed to rebuild
|
|
62
|
+
# ClaudeAgentOptions on resume. mcp_servers can't be persisted (it
|
|
63
|
+
# contains live server objects); we rebuild it from agent_id+bus+inbox
|
|
64
|
+
# in _build_options instead.
|
|
65
|
+
spawn_options = {
|
|
66
|
+
"cwd": cwd or str(self._cwd),
|
|
67
|
+
"allowed_tools": allowed_tools,
|
|
68
|
+
"disallowed_tools": disallowed_tools,
|
|
69
|
+
"model": model,
|
|
70
|
+
"system_prompt": system_prompt,
|
|
71
|
+
}
|
|
72
|
+
info = AgentInfo(
|
|
73
|
+
id=agent_id,
|
|
74
|
+
name=name,
|
|
75
|
+
cwd=cwd or str(self._cwd),
|
|
76
|
+
started_at=now,
|
|
77
|
+
spawn_options=spawn_options,
|
|
78
|
+
)
|
|
79
|
+
session = self._build_session(info)
|
|
80
|
+
self._index.upsert(info)
|
|
81
|
+
self._bus.publish(AgentSpawned(info=info))
|
|
82
|
+
|
|
83
|
+
await session.start(options=self._build_options(info))
|
|
84
|
+
await session.send(prompt)
|
|
85
|
+
return agent_id
|
|
86
|
+
|
|
87
|
+
def _build_session(self, info: AgentInfo) -> AgentSession:
|
|
88
|
+
adapter = self._adapter_factory()
|
|
89
|
+
transcript = AgentTranscript(cwd=self._cwd, agent_id=info.id)
|
|
90
|
+
session = AgentSession(
|
|
91
|
+
info=info,
|
|
92
|
+
adapter=adapter,
|
|
93
|
+
transcript=transcript,
|
|
94
|
+
bus=self._bus,
|
|
95
|
+
on_session_id=lambda sid, _id=info.id: self._on_session_id(_id, sid),
|
|
96
|
+
)
|
|
97
|
+
# Inbox lifecycle drives session state: count > 0 → WAITING,
|
|
98
|
+
# count == 0 → restore prior state. _mark_unwaiting is defensively
|
|
99
|
+
# a no-op outside WAITING, so a `kill()` mid-wait that drains the
|
|
100
|
+
# future after the session is gone is safe.
|
|
101
|
+
def _on_pending_changed(count: int, _session=session) -> None:
|
|
102
|
+
if count > 0:
|
|
103
|
+
_session._mark_waiting()
|
|
104
|
+
else:
|
|
105
|
+
_session._mark_unwaiting()
|
|
106
|
+
|
|
107
|
+
self._inboxes[info.id] = RequestInbox(
|
|
108
|
+
on_pending_changed=_on_pending_changed,
|
|
109
|
+
)
|
|
110
|
+
self._sessions[info.id] = session
|
|
111
|
+
return session
|
|
112
|
+
|
|
113
|
+
def _build_options(
|
|
114
|
+
self, info: AgentInfo, *, resume_session_id: str | None = None,
|
|
115
|
+
) -> ClaudeAgentOptions:
|
|
116
|
+
# Bypass permissions for now: there's no Textual modal to render
|
|
117
|
+
# the SDK's permission prompts in plan 2, so the child would hang.
|
|
118
|
+
# The orchestrator can still narrow what a child may do via the
|
|
119
|
+
# allowed_tools / disallowed_tools args on the spawn_agent MCP tool.
|
|
120
|
+
# A proper can_use_tool callback that pops a Textual approval modal
|
|
121
|
+
# is plan-3 work.
|
|
122
|
+
child_mcp = build_child_mcp_server(
|
|
123
|
+
agent_id=info.id, bus=self._bus, inbox=self._inboxes[info.id],
|
|
124
|
+
)
|
|
125
|
+
opts = info.spawn_options or {}
|
|
126
|
+
kwargs: dict = {
|
|
127
|
+
"cwd": opts.get("cwd") or info.cwd,
|
|
128
|
+
"permission_mode": "bypassPermissions",
|
|
129
|
+
"mcp_servers": {"patchbai_child": child_mcp},
|
|
130
|
+
}
|
|
131
|
+
if opts.get("allowed_tools") is not None:
|
|
132
|
+
kwargs["allowed_tools"] = opts["allowed_tools"]
|
|
133
|
+
if opts.get("disallowed_tools") is not None:
|
|
134
|
+
kwargs["disallowed_tools"] = opts["disallowed_tools"]
|
|
135
|
+
if opts.get("model") is not None:
|
|
136
|
+
kwargs["model"] = opts["model"]
|
|
137
|
+
if opts.get("system_prompt") is not None:
|
|
138
|
+
kwargs["system_prompt"] = opts["system_prompt"]
|
|
139
|
+
if resume_session_id is not None:
|
|
140
|
+
kwargs["resume"] = resume_session_id
|
|
141
|
+
return ClaudeAgentOptions(**kwargs)
|
|
142
|
+
|
|
143
|
+
def _on_session_id(self, agent_id: str, session_id: str) -> None:
|
|
144
|
+
# The first ResultMessage carries the SDK session id. Capture it on
|
|
145
|
+
# the persisted info so a fresh process can pass it back as resume=
|
|
146
|
+
# to keep the conversation alive.
|
|
147
|
+
session = self._sessions.get(agent_id)
|
|
148
|
+
if session is None:
|
|
149
|
+
return
|
|
150
|
+
session.info.session_id = session_id
|
|
151
|
+
self._index.upsert(session.info)
|
|
152
|
+
|
|
153
|
+
async def resume(self, agent_id: str) -> AgentSession | None:
|
|
154
|
+
# If the agent already has a live session, just hand it back.
|
|
155
|
+
existing = self._sessions.get(agent_id)
|
|
156
|
+
if existing is not None:
|
|
157
|
+
return existing
|
|
158
|
+
# Find the persisted record. If it's missing, or it predates the
|
|
159
|
+
# resume feature (no session_id / no spawn_options), we can't bring
|
|
160
|
+
# it back to life — caller must spawn fresh.
|
|
161
|
+
for info in self._index.load():
|
|
162
|
+
if info.id == agent_id:
|
|
163
|
+
target = info
|
|
164
|
+
break
|
|
165
|
+
else:
|
|
166
|
+
return None
|
|
167
|
+
if target.session_id is None or target.spawn_options is None:
|
|
168
|
+
return None
|
|
169
|
+
# Resurrect: fresh adapter, AgentSession, and SDK process pointed at
|
|
170
|
+
# the same session_id so the conversation continues.
|
|
171
|
+
target.state = AgentState.IDLE
|
|
172
|
+
target.ended_at = None
|
|
173
|
+
session = self._build_session(target)
|
|
174
|
+
self._index.upsert(target)
|
|
175
|
+
self._bus.publish(AgentSpawned(info=target))
|
|
176
|
+
await session.start(
|
|
177
|
+
options=self._build_options(target, resume_session_id=target.session_id),
|
|
178
|
+
)
|
|
179
|
+
return session
|
|
180
|
+
|
|
181
|
+
def list_infos(self) -> list[AgentInfo]:
|
|
182
|
+
return [s.info for s in self._sessions.values()]
|
|
183
|
+
|
|
184
|
+
def get_session(self, agent_id: str) -> AgentSession | None:
|
|
185
|
+
return self._sessions.get(agent_id)
|
|
186
|
+
|
|
187
|
+
def read_transcript(self, agent_id: str) -> list[TranscriptEntry]:
|
|
188
|
+
path_transcript = AgentTranscript(cwd=self._cwd, agent_id=agent_id)
|
|
189
|
+
return path_transcript.read_all()
|
|
190
|
+
|
|
191
|
+
async def interrupt(self, agent_id: str) -> None:
|
|
192
|
+
session = self._sessions.get(agent_id)
|
|
193
|
+
if session is not None:
|
|
194
|
+
await session.interrupt()
|
|
195
|
+
|
|
196
|
+
async def kill(self, agent_id: str) -> None:
|
|
197
|
+
session = self._sessions.pop(agent_id, None)
|
|
198
|
+
self._inboxes.pop(agent_id, None)
|
|
199
|
+
if session is not None:
|
|
200
|
+
await session.stop()
|
|
201
|
+
|
|
202
|
+
async def wait_idle(self, agent_id: str) -> None:
|
|
203
|
+
session = self._sessions.get(agent_id)
|
|
204
|
+
if session is not None:
|
|
205
|
+
await session.wait_idle()
|
|
206
|
+
|
|
207
|
+
async def send(self, agent_id: str, text: str) -> None:
|
|
208
|
+
session = self._sessions.get(agent_id)
|
|
209
|
+
if session is None:
|
|
210
|
+
raise KeyError(f"unknown agent_id: {agent_id}")
|
|
211
|
+
await session.send(text)
|
|
212
|
+
|
|
213
|
+
def get_inbox(self, agent_id: str) -> RequestInbox | None:
|
|
214
|
+
return self._inboxes.get(agent_id)
|
|
215
|
+
|
|
216
|
+
def set_archived(self, agent_id: str, *, archived: bool) -> None:
|
|
217
|
+
"""Toggle the archived flag for an agent. Persists to agents.json and
|
|
218
|
+
publishes AgentArchiveChanged so listeners (e.g., AgentTable) can
|
|
219
|
+
refresh. Raises KeyError if `agent_id` is unknown."""
|
|
220
|
+
# The archived flag is metadata, not runtime state — agents from a
|
|
221
|
+
# previous process show up in the table (seeded from agents.json) but
|
|
222
|
+
# have no live session here. Fall back to the persisted record so
|
|
223
|
+
# archive/unarchive works on those rows too.
|
|
224
|
+
session = self._sessions.get(agent_id)
|
|
225
|
+
if session is not None:
|
|
226
|
+
info = session.info
|
|
227
|
+
else:
|
|
228
|
+
info = next(
|
|
229
|
+
(i for i in self._index.load() if i.id == agent_id), None
|
|
230
|
+
)
|
|
231
|
+
if info is None:
|
|
232
|
+
raise KeyError(f"unknown agent_id: {agent_id}")
|
|
233
|
+
if info.archived == archived:
|
|
234
|
+
return
|
|
235
|
+
info.archived = archived
|
|
236
|
+
self._index.upsert(info)
|
|
237
|
+
# Publish a frozen snapshot so subscribers see a stable view.
|
|
238
|
+
self._bus.publish(AgentArchiveChanged(info=dataclasses.replace(info)))
|
|
239
|
+
|
|
240
|
+
async def shutdown(self) -> None:
|
|
241
|
+
for agent_id in list(self._sessions.keys()):
|
|
242
|
+
await self.kill(agent_id)
|
|
243
|
+
self._unsub_state()
|
|
244
|
+
self._unsub_direct()
|
|
245
|
+
|
|
246
|
+
# --- internals --------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
def _on_state_changed(self, event: AgentStateChanged) -> None:
|
|
249
|
+
# Persist updated info on every state change so agents.json reflects reality.
|
|
250
|
+
self._index.upsert(event.info)
|
|
251
|
+
|
|
252
|
+
def _on_direct_message(self, event: DirectMessageToAgent) -> None:
|
|
253
|
+
session = self._sessions.get(event.agent_id)
|
|
254
|
+
if session is not None:
|
|
255
|
+
session.queue_send(event.text)
|
|
256
|
+
return
|
|
257
|
+
# No live session: this is a record from a previous process. Try to
|
|
258
|
+
# resurrect it via SDK resume so the user's message lands on a real
|
|
259
|
+
# conversation. Schedule on the running loop because EventBus
|
|
260
|
+
# handlers must be sync.
|
|
261
|
+
import asyncio
|
|
262
|
+
try:
|
|
263
|
+
loop = asyncio.get_running_loop()
|
|
264
|
+
except RuntimeError:
|
|
265
|
+
return # no loop — nothing we can do (e.g. unit-test without loop)
|
|
266
|
+
loop.create_task(self._resume_then_send(event.agent_id, event.text))
|
|
267
|
+
|
|
268
|
+
async def _resume_then_send(self, agent_id: str, text: str) -> None:
|
|
269
|
+
session = await self.resume(agent_id)
|
|
270
|
+
if session is None:
|
|
271
|
+
return # legacy record without session_id/spawn_options
|
|
272
|
+
session.queue_send(text)
|