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
@@ -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
@@ -0,0 +1,224 @@
1
+ import asyncio
2
+ import dataclasses
3
+ import time
4
+ from typing import Callable, Iterable
5
+
6
+ from claude_agent_sdk import (
7
+ AssistantMessage,
8
+ ClaudeAgentOptions,
9
+ ResultMessage,
10
+ SystemMessage,
11
+ TextBlock,
12
+ ThinkingBlock,
13
+ ToolResultBlock,
14
+ ToolUseBlock,
15
+ UserMessage,
16
+ )
17
+
18
+ from patchbai.agents.sdk_adapter import SDKAdapter
19
+ from patchbai.agents.state import AgentInfo, AgentState
20
+ from patchbai.events import (
21
+ AgentMessageAppended,
22
+ AgentStateChanged,
23
+ AgentTokensTouched,
24
+ EventBus,
25
+ )
26
+ from patchbai.persistence.transcript_store import AgentTranscript, TranscriptEntry
27
+
28
+
29
+ class AgentSession:
30
+ """One Claude Agent SDK session: one adapter, one transcript, one state machine."""
31
+
32
+ def __init__(
33
+ self,
34
+ *,
35
+ info: AgentInfo,
36
+ adapter: SDKAdapter,
37
+ transcript: AgentTranscript,
38
+ bus: EventBus,
39
+ on_session_id: "Callable[[str], None] | None" = None,
40
+ ) -> None:
41
+ self.info = info
42
+ self._adapter = adapter
43
+ self._transcript = transcript
44
+ self._bus = bus
45
+ self._on_session_id = on_session_id
46
+ self._session_id: str | None = None
47
+ self._stream_task: asyncio.Task | None = None
48
+ self._idle_event = asyncio.Event()
49
+ self._idle_event.set()
50
+ self._send_lock = asyncio.Lock()
51
+ self._pre_wait_state: AgentState | None = None
52
+
53
+ @property
54
+ def session_id(self) -> str | None:
55
+ return self._session_id
56
+
57
+ async def start(self, *, options: ClaudeAgentOptions) -> None:
58
+ await self._adapter.start(options=options)
59
+
60
+ async def send(self, prompt: str) -> None:
61
+ async with self._send_lock:
62
+ # If the previous stream is still draining, wait for it before
63
+ # issuing the next query — the SDK doesn't support overlapping
64
+ # query() calls on a single session.
65
+ if self._stream_task is not None and not self._stream_task.done():
66
+ await self._stream_task
67
+
68
+ self._record(role="user", text=prompt)
69
+ await self._adapter.query(prompt)
70
+ self._set_state(AgentState.RUNNING)
71
+ self._idle_event.clear()
72
+ self._stream_task = asyncio.create_task(self._consume_stream())
73
+
74
+ def queue_send(self, prompt: str) -> "asyncio.Task":
75
+ """Schedule a send() on the running event loop and return the Task.
76
+
77
+ Eagerly clears `_idle_event` synchronously so a subsequent wait_idle()
78
+ in the same task will correctly block until the send completes —
79
+ without it, wait_idle could return before the send task acquires the
80
+ send lock.
81
+ """
82
+ self._idle_event.clear()
83
+ return asyncio.create_task(self.send(prompt))
84
+
85
+ async def wait_idle(self) -> None:
86
+ await self._idle_event.wait()
87
+
88
+ async def interrupt(self) -> None:
89
+ await self._adapter.interrupt()
90
+
91
+ async def stop(self) -> None:
92
+ if self._stream_task is not None and not self._stream_task.done():
93
+ self._stream_task.cancel()
94
+ try:
95
+ await self._stream_task
96
+ except (asyncio.CancelledError, Exception):
97
+ pass
98
+ await self._adapter.stop()
99
+
100
+ # --- internals --------------------------------------------------------
101
+
102
+ async def _consume_stream(self) -> None:
103
+ try:
104
+ async for msg in self._adapter.stream():
105
+ self._handle_message(msg)
106
+ self._set_state(AgentState.DONE)
107
+ except Exception:
108
+ self._set_state(AgentState.ERROR)
109
+ finally:
110
+ self._idle_event.set()
111
+
112
+ def _handle_message(self, msg: object) -> None:
113
+ if isinstance(msg, AssistantMessage):
114
+ for block in msg.content:
115
+ if isinstance(block, TextBlock):
116
+ self._record(role="assistant", text=block.text)
117
+ elif isinstance(block, ToolUseBlock):
118
+ self._record(
119
+ role="tool_use",
120
+ text=f"[{block.name}] {_short_repr(block.input)}",
121
+ tool_id=block.id,
122
+ tool_name=block.name,
123
+ )
124
+ elif isinstance(block, ThinkingBlock):
125
+ self._record(role="thinking", text=block.thinking)
126
+ elif isinstance(msg, UserMessage):
127
+ for block in msg.content:
128
+ if isinstance(block, ToolResultBlock):
129
+ # tool_name not available on result blocks; consumers match via tool_id
130
+ self._record(
131
+ role="tool_result",
132
+ text=_short_repr(block.content),
133
+ tool_id=block.tool_use_id,
134
+ )
135
+ elif isinstance(msg, SystemMessage):
136
+ # Skip — verbose protocol noise.
137
+ pass
138
+ elif isinstance(msg, ResultMessage):
139
+ if self._session_id is None and msg.session_id:
140
+ self._session_id = msg.session_id
141
+ if self._on_session_id is not None:
142
+ try:
143
+ self._on_session_id(msg.session_id)
144
+ except Exception:
145
+ # Callback errors must not poison the SDK stream.
146
+ pass
147
+ usage = msg.usage or {}
148
+ tokens_in = int(usage.get("input_tokens", 0) or 0)
149
+ tokens_out = int(usage.get("output_tokens", 0) or 0)
150
+ self.info.tokens_in += tokens_in
151
+ self.info.tokens_out += tokens_out
152
+ if msg.total_cost_usd is not None:
153
+ self.info.cost += float(msg.total_cost_usd)
154
+ if tokens_in or tokens_out or msg.total_cost_usd:
155
+ self._bus.publish(AgentTokensTouched(agent_id=self.info.id))
156
+ self.info.last_activity = time.time()
157
+
158
+ def _record(
159
+ self,
160
+ *,
161
+ role: str,
162
+ text: str,
163
+ tool_id: str | None = None,
164
+ tool_name: str | None = None,
165
+ ) -> None:
166
+ entry = TranscriptEntry(
167
+ role=role, text=text, tool_id=tool_id, tool_name=tool_name,
168
+ )
169
+ self._transcript.append(entry)
170
+ self._bus.publish(
171
+ AgentMessageAppended(
172
+ agent_id=self.info.id, role=role, text=text,
173
+ tool_id=tool_id, tool_name=tool_name,
174
+ )
175
+ )
176
+ self.info.last_activity = time.time()
177
+
178
+ def _mark_waiting(self) -> None:
179
+ """Enter WAITING state, snapshotting the prior state for restore.
180
+
181
+ Idempotent: a second call while already WAITING is a no-op (the
182
+ snapshot is preserved). Skipped if the session is already in a
183
+ terminal state.
184
+ """
185
+ if self.info.state.is_terminal:
186
+ return
187
+ if self.info.state == AgentState.WAITING:
188
+ return
189
+ self._pre_wait_state = self.info.state
190
+ self._set_state(AgentState.WAITING)
191
+
192
+ def _mark_unwaiting(self) -> None:
193
+ """Exit WAITING state, restoring the pre-wait state.
194
+
195
+ No-op when not in WAITING. If the session is somehow terminal,
196
+ the snapshot is dropped without a transition.
197
+ """
198
+ if self.info.state != AgentState.WAITING:
199
+ self._pre_wait_state = None
200
+ return
201
+ target = self._pre_wait_state or AgentState.RUNNING
202
+ self._pre_wait_state = None
203
+ if target.is_terminal:
204
+ # Defensive: never resurrect a terminal state.
205
+ return
206
+ self._set_state(target)
207
+
208
+ def _set_state(self, new_state: AgentState) -> None:
209
+ old = self.info.state
210
+ if old == new_state:
211
+ return
212
+ self.info.state = new_state
213
+ if new_state.is_terminal:
214
+ self.info.ended_at = time.time()
215
+ # Publish a frozen snapshot so subscribers see the state at publish time,
216
+ # not a live reference that may mutate before they inspect it.
217
+ self._bus.publish(
218
+ AgentStateChanged(info=dataclasses.replace(self.info), old_state=old)
219
+ )
220
+
221
+
222
+ def _short_repr(value: object, limit: int = 200) -> str:
223
+ s = repr(value)
224
+ return s if len(s) <= limit else s[: limit - 1] + "…"
@@ -0,0 +1,66 @@
1
+ """Default sort order for the AgentTable widget.
2
+
3
+ The sort surfaces agents that need attention at the top:
4
+
5
+ WAITING (blocked on user/orchestrator) → RUNNING/IDLE (live)
6
+ → ERROR (triage) → DONE (terminal, least interesting)
7
+
8
+ Within a bucket, rows order by recent-activity desc, with `started_at`
9
+ asc as a stable final tiebreaker. Archived agents always sink to the
10
+ bottom regardless of state.
11
+
12
+ This is the v1 default sort. User-overridable column-click sort is
13
+ deliberately out of scope; see plan
14
+ `docs/superpowers/plans/2026-05-07-agent-table-default-sort.md`.
15
+
16
+ When `AWAITING_PERMISSION` (currently only on the approval-modals
17
+ branch) merges, add it to STATE_PRIORITY at priority 0 — same
18
+ semantic as WAITING.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Iterable
24
+
25
+ from patchbai.agents.state import AgentInfo, AgentState
26
+
27
+ STATE_PRIORITY: dict[AgentState, int] = {
28
+ AgentState.WAITING: 0,
29
+ AgentState.RUNNING: 1,
30
+ AgentState.IDLE: 2,
31
+ AgentState.ERROR: 3,
32
+ AgentState.DONE: 4,
33
+ }
34
+
35
+
36
+ def _sort_key(info: AgentInfo) -> tuple:
37
+ # Archived agents sink past every live row regardless of state.
38
+ archived_bucket = 1 if info.archived else 0
39
+
40
+ # Within the archived bucket, state ordering doesn't matter — every
41
+ # archived row is "out of sight". Collapse them into a single state
42
+ # bucket so they sort purely by ended_at desc among themselves.
43
+ if info.archived:
44
+ state_bucket = 0
45
+ else:
46
+ state_bucket = STATE_PRIORITY.get(info.state, len(STATE_PRIORITY))
47
+
48
+ # For terminal/archived rows, prefer ended_at as the "when" timestamp;
49
+ # last_activity is the fallback so legacy rows without ended_at still sort.
50
+ if info.archived or info.state == AgentState.DONE:
51
+ when = info.ended_at if info.ended_at is not None else info.last_activity
52
+ else:
53
+ when = info.last_activity
54
+
55
+ # Tuple-compare: smaller archived/state buckets win; -when sorts desc;
56
+ # started_at asc breaks ties stably.
57
+ return (archived_bucket, state_bucket, -when, info.started_at)
58
+
59
+
60
+ def sort_agents(infos: Iterable[AgentInfo]) -> list[AgentInfo]:
61
+ """Return `infos` ordered for default AgentTable display.
62
+
63
+ Pure: no side effects, no Textual coupling. Caller passes whatever
64
+ snapshot they want sorted (live, persisted, mixed).
65
+ """
66
+ return sorted(infos, key=_sort_key)
@@ -0,0 +1,80 @@
1
+ from dataclasses import dataclass, field
2
+ from enum import Enum
3
+
4
+
5
+ class AgentState(str, Enum):
6
+ IDLE = "idle"
7
+ RUNNING = "running"
8
+ WAITING = "waiting"
9
+ DONE = "done"
10
+ ERROR = "error"
11
+
12
+ @property
13
+ def is_terminal(self) -> bool:
14
+ return self in (AgentState.DONE, AgentState.ERROR)
15
+
16
+
17
+ @dataclass
18
+ class AgentInfo:
19
+ id: str
20
+ name: str
21
+ cwd: str
22
+ started_at: float
23
+ state: AgentState = AgentState.IDLE
24
+ ended_at: float | None = None
25
+ last_activity: float = field(default=0.0)
26
+ cost: float = 0.0
27
+ tokens_in: int = 0
28
+ tokens_out: int = 0
29
+ archived: bool = False
30
+ # SDK session id observed from the first ResultMessage. Required to
31
+ # resume the conversation in a fresh process after a crash/restart.
32
+ session_id: str | None = None
33
+ # JSON-serializable kwargs needed to reconstruct ClaudeAgentOptions on
34
+ # resume (cwd, model, allowed_tools, disallowed_tools, system_prompt).
35
+ # None means this record was written before the resume feature existed
36
+ # and cannot be auto-resumed.
37
+ spawn_options: dict | None = None
38
+
39
+ def __post_init__(self) -> None:
40
+ if self.last_activity == 0.0:
41
+ self.last_activity = self.started_at
42
+
43
+ def elapsed_seconds(self) -> float:
44
+ end = self.ended_at if self.ended_at is not None else self.last_activity
45
+ return end - self.started_at
46
+
47
+ def to_dict(self) -> dict:
48
+ return {
49
+ "id": self.id,
50
+ "name": self.name,
51
+ "cwd": self.cwd,
52
+ "started_at": self.started_at,
53
+ "state": self.state.value,
54
+ "ended_at": self.ended_at,
55
+ "last_activity": self.last_activity,
56
+ "cost": self.cost,
57
+ "tokens_in": self.tokens_in,
58
+ "tokens_out": self.tokens_out,
59
+ "archived": self.archived,
60
+ "session_id": self.session_id,
61
+ "spawn_options": self.spawn_options,
62
+ }
63
+
64
+ @classmethod
65
+ def from_dict(cls, d: dict) -> "AgentInfo":
66
+ return cls(
67
+ id=d["id"],
68
+ name=d["name"],
69
+ cwd=d["cwd"],
70
+ started_at=d["started_at"],
71
+ state=AgentState(d["state"]),
72
+ ended_at=d.get("ended_at"),
73
+ last_activity=d.get("last_activity", d["started_at"]),
74
+ cost=d.get("cost", 0.0),
75
+ tokens_in=d.get("tokens_in", 0),
76
+ tokens_out=d.get("tokens_out", 0),
77
+ archived=d.get("archived", False),
78
+ session_id=d.get("session_id"),
79
+ spawn_options=d.get("spawn_options"),
80
+ )