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