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