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
patchfeld/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.1"
patchfeld/__main__.py ADDED
@@ -0,0 +1,32 @@
1
+ import argparse
2
+ import sys
3
+
4
+ from patchfeld.app import PatchfeldApp
5
+
6
+
7
+ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
8
+ parser = argparse.ArgumentParser(
9
+ prog="patchfeld",
10
+ description="Multi-agent Textual TUI for Claude Agent SDK.",
11
+ )
12
+ parser.add_argument(
13
+ "--bypass-permissions",
14
+ action="store_true",
15
+ help=(
16
+ "Run all sessions (orchestrator + child agents) with "
17
+ "permission_mode=bypassPermissions. Default behavior is to "
18
+ "ask for confirmation via a Textual modal before every "
19
+ "tool call."
20
+ ),
21
+ )
22
+ return parser.parse_args(argv)
23
+
24
+
25
+ def main(argv: list[str] | None = None) -> int:
26
+ args = _parse_args(argv if argv is not None else sys.argv[1:])
27
+ PatchfeldApp(bypass_permissions=args.bypass_permissions).run()
28
+ return 0
29
+
30
+
31
+ if __name__ == "__main__":
32
+ raise SystemExit(main())
patchfeld/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 patchfeld.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 patchfeld.agents.request_inbox import RequestInbox
6
+ from patchfeld.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="patchfeld_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