patchfeld 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- patchfeld/__init__.py +1 -0
- patchfeld/__main__.py +32 -0
- patchfeld/actions.py +34 -0
- patchfeld/activity/__init__.py +0 -0
- patchfeld/activity/log.py +237 -0
- patchfeld/agents/__init__.py +0 -0
- patchfeld/agents/child_tools.py +66 -0
- patchfeld/agents/fake_sdk_adapter.py +45 -0
- patchfeld/agents/manager.py +365 -0
- patchfeld/agents/permission_grants.py +98 -0
- patchfeld/agents/permission_inbox.py +91 -0
- patchfeld/agents/request_inbox.py +65 -0
- patchfeld/agents/sdk_adapter.py +49 -0
- patchfeld/agents/session.py +250 -0
- patchfeld/agents/sort.py +66 -0
- patchfeld/agents/state.py +81 -0
- patchfeld/app.py +1433 -0
- patchfeld/config.py +128 -0
- patchfeld/events.py +260 -0
- patchfeld/layout/__init__.py +0 -0
- patchfeld/layout/custom_widgets.py +82 -0
- patchfeld/layout/defaults.py +33 -0
- patchfeld/layout/engine.py +241 -0
- patchfeld/layout/local_widgets.py +188 -0
- patchfeld/layout/registry.py +69 -0
- patchfeld/layout/spec.py +104 -0
- patchfeld/layout/splitter.py +170 -0
- patchfeld/layout/titles.py +70 -0
- patchfeld/orchestrator/__init__.py +0 -0
- patchfeld/orchestrator/formatting.py +15 -0
- patchfeld/orchestrator/session.py +785 -0
- patchfeld/orchestrator/tabs_tools.py +149 -0
- patchfeld/orchestrator/tools.py +976 -0
- patchfeld/persistence/__init__.py +0 -0
- patchfeld/persistence/agents_index.py +68 -0
- patchfeld/persistence/atomic.py +47 -0
- patchfeld/persistence/layout_store.py +25 -0
- patchfeld/persistence/layouts_store.py +61 -0
- patchfeld/persistence/orchestrator_sessions.py +127 -0
- patchfeld/persistence/paths.py +48 -0
- patchfeld/persistence/themes_store.py +44 -0
- patchfeld/persistence/transcript_store.py +64 -0
- patchfeld/persistence/workspace_store.py +25 -0
- patchfeld/theme/__init__.py +0 -0
- patchfeld/theme/engine.py +75 -0
- patchfeld/theme/spec.py +31 -0
- patchfeld/widgets/__init__.py +0 -0
- patchfeld/widgets/_file_lang.py +36 -0
- patchfeld/widgets/_terminal_keys.py +89 -0
- patchfeld/widgets/_terminal_render.py +147 -0
- patchfeld/widgets/activity_feed.py +365 -0
- patchfeld/widgets/agent_table.py +236 -0
- patchfeld/widgets/agent_transcript.py +85 -0
- patchfeld/widgets/change_cwd_screen.py +39 -0
- patchfeld/widgets/chrome.py +210 -0
- patchfeld/widgets/diff_viewer.py +52 -0
- patchfeld/widgets/file_editor.py +258 -0
- patchfeld/widgets/file_tree.py +33 -0
- patchfeld/widgets/file_viewer.py +77 -0
- patchfeld/widgets/history_screen.py +58 -0
- patchfeld/widgets/layout_switcher.py +126 -0
- patchfeld/widgets/log_tail.py +113 -0
- patchfeld/widgets/markdown.py +65 -0
- patchfeld/widgets/new_tab_screen.py +31 -0
- patchfeld/widgets/notebook.py +45 -0
- patchfeld/widgets/orchestrator_chat.py +73 -0
- patchfeld/widgets/permission_modal.py +185 -0
- patchfeld/widgets/permission_request_bar.py +90 -0
- patchfeld/widgets/resume_screen.py +179 -0
- patchfeld/widgets/rich_transcript.py +606 -0
- patchfeld/widgets/system_usage.py +244 -0
- patchfeld/widgets/terminal.py +251 -0
- patchfeld/widgets/theme_switcher.py +63 -0
- patchfeld/widgets/transcript_screen.py +39 -0
- patchfeld/workspace/__init__.py +3 -0
- patchfeld/workspace/spec.py +72 -0
- patchfeld-0.2.0.dist-info/METADATA +584 -0
- patchfeld-0.2.0.dist-info/RECORD +81 -0
- patchfeld-0.2.0.dist-info/WHEEL +4 -0
- patchfeld-0.2.0.dist-info/entry_points.txt +3 -0
- patchfeld-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,250 @@
|
|
|
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 patchfeld.agents.sdk_adapter import SDKAdapter
|
|
19
|
+
from patchfeld.agents.state import AgentInfo, AgentState
|
|
20
|
+
from patchfeld.events import (
|
|
21
|
+
AgentMessageAppended,
|
|
22
|
+
AgentStateChanged,
|
|
23
|
+
AgentTokensTouched,
|
|
24
|
+
EventBus,
|
|
25
|
+
)
|
|
26
|
+
from patchfeld.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
|
+
self._pre_perm_state: AgentState | None = None
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def session_id(self) -> str | None:
|
|
56
|
+
return self._session_id
|
|
57
|
+
|
|
58
|
+
async def start(self, *, options: ClaudeAgentOptions) -> None:
|
|
59
|
+
await self._adapter.start(options=options)
|
|
60
|
+
|
|
61
|
+
async def send(self, prompt: str) -> None:
|
|
62
|
+
async with self._send_lock:
|
|
63
|
+
# If the previous stream is still draining, wait for it before
|
|
64
|
+
# issuing the next query — the SDK doesn't support overlapping
|
|
65
|
+
# query() calls on a single session.
|
|
66
|
+
if self._stream_task is not None and not self._stream_task.done():
|
|
67
|
+
await self._stream_task
|
|
68
|
+
|
|
69
|
+
self._record(role="user", text=prompt)
|
|
70
|
+
await self._adapter.query(prompt)
|
|
71
|
+
self._set_state(AgentState.RUNNING)
|
|
72
|
+
self._idle_event.clear()
|
|
73
|
+
self._stream_task = asyncio.create_task(self._consume_stream())
|
|
74
|
+
|
|
75
|
+
def queue_send(self, prompt: str) -> "asyncio.Task":
|
|
76
|
+
"""Schedule a send() on the running event loop and return the Task.
|
|
77
|
+
|
|
78
|
+
Eagerly clears `_idle_event` synchronously so a subsequent wait_idle()
|
|
79
|
+
in the same task will correctly block until the send completes —
|
|
80
|
+
without it, wait_idle could return before the send task acquires the
|
|
81
|
+
send lock.
|
|
82
|
+
"""
|
|
83
|
+
self._idle_event.clear()
|
|
84
|
+
return asyncio.create_task(self.send(prompt))
|
|
85
|
+
|
|
86
|
+
async def wait_idle(self) -> None:
|
|
87
|
+
await self._idle_event.wait()
|
|
88
|
+
|
|
89
|
+
async def interrupt(self) -> None:
|
|
90
|
+
await self._adapter.interrupt()
|
|
91
|
+
|
|
92
|
+
async def stop(self) -> None:
|
|
93
|
+
if self._stream_task is not None and not self._stream_task.done():
|
|
94
|
+
self._stream_task.cancel()
|
|
95
|
+
try:
|
|
96
|
+
await self._stream_task
|
|
97
|
+
except (asyncio.CancelledError, Exception):
|
|
98
|
+
pass
|
|
99
|
+
await self._adapter.stop()
|
|
100
|
+
|
|
101
|
+
# --- internals --------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
async def _consume_stream(self) -> None:
|
|
104
|
+
try:
|
|
105
|
+
async for msg in self._adapter.stream():
|
|
106
|
+
self._handle_message(msg)
|
|
107
|
+
self._set_state(AgentState.DONE)
|
|
108
|
+
except Exception:
|
|
109
|
+
self._set_state(AgentState.ERROR)
|
|
110
|
+
finally:
|
|
111
|
+
self._idle_event.set()
|
|
112
|
+
|
|
113
|
+
def _handle_message(self, msg: object) -> None:
|
|
114
|
+
if isinstance(msg, AssistantMessage):
|
|
115
|
+
for block in msg.content:
|
|
116
|
+
if isinstance(block, TextBlock):
|
|
117
|
+
self._record(role="assistant", text=block.text)
|
|
118
|
+
elif isinstance(block, ToolUseBlock):
|
|
119
|
+
self._record(
|
|
120
|
+
role="tool_use",
|
|
121
|
+
text=f"[{block.name}] {_short_repr(block.input)}",
|
|
122
|
+
tool_id=block.id,
|
|
123
|
+
tool_name=block.name,
|
|
124
|
+
)
|
|
125
|
+
elif isinstance(block, ThinkingBlock):
|
|
126
|
+
self._record(role="thinking", text=block.thinking)
|
|
127
|
+
elif isinstance(msg, UserMessage):
|
|
128
|
+
for block in msg.content:
|
|
129
|
+
if isinstance(block, ToolResultBlock):
|
|
130
|
+
# tool_name not available on result blocks; consumers match via tool_id
|
|
131
|
+
self._record(
|
|
132
|
+
role="tool_result",
|
|
133
|
+
text=_short_repr(block.content),
|
|
134
|
+
tool_id=block.tool_use_id,
|
|
135
|
+
)
|
|
136
|
+
elif isinstance(msg, SystemMessage):
|
|
137
|
+
# Skip — verbose protocol noise.
|
|
138
|
+
pass
|
|
139
|
+
elif isinstance(msg, ResultMessage):
|
|
140
|
+
if self._session_id is None and msg.session_id:
|
|
141
|
+
self._session_id = msg.session_id
|
|
142
|
+
if self._on_session_id is not None:
|
|
143
|
+
try:
|
|
144
|
+
self._on_session_id(msg.session_id)
|
|
145
|
+
except Exception:
|
|
146
|
+
# Callback errors must not poison the SDK stream.
|
|
147
|
+
pass
|
|
148
|
+
usage = msg.usage or {}
|
|
149
|
+
tokens_in = int(usage.get("input_tokens", 0) or 0)
|
|
150
|
+
tokens_out = int(usage.get("output_tokens", 0) or 0)
|
|
151
|
+
self.info.tokens_in += tokens_in
|
|
152
|
+
self.info.tokens_out += tokens_out
|
|
153
|
+
if msg.total_cost_usd is not None:
|
|
154
|
+
self.info.cost += float(msg.total_cost_usd)
|
|
155
|
+
if tokens_in or tokens_out or msg.total_cost_usd:
|
|
156
|
+
self._bus.publish(AgentTokensTouched(agent_id=self.info.id))
|
|
157
|
+
self.info.last_activity = time.time()
|
|
158
|
+
|
|
159
|
+
def _record(
|
|
160
|
+
self,
|
|
161
|
+
*,
|
|
162
|
+
role: str,
|
|
163
|
+
text: str,
|
|
164
|
+
tool_id: str | None = None,
|
|
165
|
+
tool_name: str | None = None,
|
|
166
|
+
) -> None:
|
|
167
|
+
entry = TranscriptEntry(
|
|
168
|
+
role=role, text=text, tool_id=tool_id, tool_name=tool_name,
|
|
169
|
+
)
|
|
170
|
+
self._transcript.append(entry)
|
|
171
|
+
self._bus.publish(
|
|
172
|
+
AgentMessageAppended(
|
|
173
|
+
agent_id=self.info.id, role=role, text=text,
|
|
174
|
+
tool_id=tool_id, tool_name=tool_name,
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
self.info.last_activity = time.time()
|
|
178
|
+
|
|
179
|
+
def _mark_waiting(self) -> None:
|
|
180
|
+
"""Enter WAITING state, snapshotting the prior state for restore.
|
|
181
|
+
|
|
182
|
+
Idempotent: a second call while already WAITING is a no-op (the
|
|
183
|
+
snapshot is preserved). Skipped if the session is already in a
|
|
184
|
+
terminal state.
|
|
185
|
+
"""
|
|
186
|
+
if self.info.state.is_terminal:
|
|
187
|
+
return
|
|
188
|
+
if self.info.state == AgentState.WAITING:
|
|
189
|
+
return
|
|
190
|
+
self._pre_wait_state = self.info.state
|
|
191
|
+
self._set_state(AgentState.WAITING)
|
|
192
|
+
|
|
193
|
+
def _mark_unwaiting(self) -> None:
|
|
194
|
+
"""Exit WAITING state, restoring the pre-wait state.
|
|
195
|
+
|
|
196
|
+
No-op when not in WAITING. If the session is somehow terminal,
|
|
197
|
+
the snapshot is dropped without a transition.
|
|
198
|
+
"""
|
|
199
|
+
if self.info.state != AgentState.WAITING:
|
|
200
|
+
self._pre_wait_state = None
|
|
201
|
+
return
|
|
202
|
+
target = self._pre_wait_state or AgentState.RUNNING
|
|
203
|
+
self._pre_wait_state = None
|
|
204
|
+
if target.is_terminal:
|
|
205
|
+
# Defensive: never resurrect a terminal state.
|
|
206
|
+
return
|
|
207
|
+
self._set_state(target)
|
|
208
|
+
|
|
209
|
+
def _mark_awaiting_permission(self) -> None:
|
|
210
|
+
"""Enter AWAITING_PERMISSION, snapshotting the prior state.
|
|
211
|
+
|
|
212
|
+
Idempotent. Composes with _mark_waiting: a session can transition
|
|
213
|
+
RUNNING → AWAITING_PERMISSION → WAITING → AWAITING_PERMISSION
|
|
214
|
+
→ RUNNING and end up where it started. Skipped if terminal.
|
|
215
|
+
"""
|
|
216
|
+
if self.info.state.is_terminal:
|
|
217
|
+
return
|
|
218
|
+
if self.info.state == AgentState.AWAITING_PERMISSION:
|
|
219
|
+
return
|
|
220
|
+
self._pre_perm_state = self.info.state
|
|
221
|
+
self._set_state(AgentState.AWAITING_PERMISSION)
|
|
222
|
+
|
|
223
|
+
def _mark_done_permission(self) -> None:
|
|
224
|
+
"""Exit AWAITING_PERMISSION, restoring the pre-permission state."""
|
|
225
|
+
if self.info.state != AgentState.AWAITING_PERMISSION:
|
|
226
|
+
self._pre_perm_state = None
|
|
227
|
+
return
|
|
228
|
+
target = self._pre_perm_state or AgentState.RUNNING
|
|
229
|
+
self._pre_perm_state = None
|
|
230
|
+
if target.is_terminal:
|
|
231
|
+
return
|
|
232
|
+
self._set_state(target)
|
|
233
|
+
|
|
234
|
+
def _set_state(self, new_state: AgentState) -> None:
|
|
235
|
+
old = self.info.state
|
|
236
|
+
if old == new_state:
|
|
237
|
+
return
|
|
238
|
+
self.info.state = new_state
|
|
239
|
+
if new_state.is_terminal:
|
|
240
|
+
self.info.ended_at = time.time()
|
|
241
|
+
# Publish a frozen snapshot so subscribers see the state at publish time,
|
|
242
|
+
# not a live reference that may mutate before they inspect it.
|
|
243
|
+
self._bus.publish(
|
|
244
|
+
AgentStateChanged(info=dataclasses.replace(self.info), old_state=old)
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _short_repr(value: object, limit: int = 200) -> str:
|
|
249
|
+
s = repr(value)
|
|
250
|
+
return s if len(s) <= limit else s[: limit - 1] + "…"
|
patchfeld/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 patchfeld.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,81 @@
|
|
|
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
|
+
AWAITING_PERMISSION = "awaiting_permission"
|
|
10
|
+
DONE = "done"
|
|
11
|
+
ERROR = "error"
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def is_terminal(self) -> bool:
|
|
15
|
+
return self in (AgentState.DONE, AgentState.ERROR)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class AgentInfo:
|
|
20
|
+
id: str
|
|
21
|
+
name: str
|
|
22
|
+
cwd: str
|
|
23
|
+
started_at: float
|
|
24
|
+
state: AgentState = AgentState.IDLE
|
|
25
|
+
ended_at: float | None = None
|
|
26
|
+
last_activity: float = field(default=0.0)
|
|
27
|
+
cost: float = 0.0
|
|
28
|
+
tokens_in: int = 0
|
|
29
|
+
tokens_out: int = 0
|
|
30
|
+
archived: bool = False
|
|
31
|
+
# SDK session id observed from the first ResultMessage. Required to
|
|
32
|
+
# resume the conversation in a fresh process after a crash/restart.
|
|
33
|
+
session_id: str | None = None
|
|
34
|
+
# JSON-serializable kwargs needed to reconstruct ClaudeAgentOptions on
|
|
35
|
+
# resume (cwd, model, allowed_tools, disallowed_tools, system_prompt).
|
|
36
|
+
# None means this record was written before the resume feature existed
|
|
37
|
+
# and cannot be auto-resumed.
|
|
38
|
+
spawn_options: dict | None = None
|
|
39
|
+
|
|
40
|
+
def __post_init__(self) -> None:
|
|
41
|
+
if self.last_activity == 0.0:
|
|
42
|
+
self.last_activity = self.started_at
|
|
43
|
+
|
|
44
|
+
def elapsed_seconds(self) -> float:
|
|
45
|
+
end = self.ended_at if self.ended_at is not None else self.last_activity
|
|
46
|
+
return end - self.started_at
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> dict:
|
|
49
|
+
return {
|
|
50
|
+
"id": self.id,
|
|
51
|
+
"name": self.name,
|
|
52
|
+
"cwd": self.cwd,
|
|
53
|
+
"started_at": self.started_at,
|
|
54
|
+
"state": self.state.value,
|
|
55
|
+
"ended_at": self.ended_at,
|
|
56
|
+
"last_activity": self.last_activity,
|
|
57
|
+
"cost": self.cost,
|
|
58
|
+
"tokens_in": self.tokens_in,
|
|
59
|
+
"tokens_out": self.tokens_out,
|
|
60
|
+
"archived": self.archived,
|
|
61
|
+
"session_id": self.session_id,
|
|
62
|
+
"spawn_options": self.spawn_options,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_dict(cls, d: dict) -> "AgentInfo":
|
|
67
|
+
return cls(
|
|
68
|
+
id=d["id"],
|
|
69
|
+
name=d["name"],
|
|
70
|
+
cwd=d["cwd"],
|
|
71
|
+
started_at=d["started_at"],
|
|
72
|
+
state=AgentState(d["state"]),
|
|
73
|
+
ended_at=d.get("ended_at"),
|
|
74
|
+
last_activity=d.get("last_activity", d["started_at"]),
|
|
75
|
+
cost=d.get("cost", 0.0),
|
|
76
|
+
tokens_in=d.get("tokens_in", 0),
|
|
77
|
+
tokens_out=d.get("tokens_out", 0),
|
|
78
|
+
archived=d.get("archived", False),
|
|
79
|
+
session_id=d.get("session_id"),
|
|
80
|
+
spawn_options=d.get("spawn_options"),
|
|
81
|
+
)
|