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
|
@@ -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] + "…"
|
patchbai/agents/sort.py
ADDED
|
@@ -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)
|
patchbai/agents/state.py
ADDED
|
@@ -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
|
+
)
|