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,976 @@
1
+ import json
2
+ import re
3
+ from dataclasses import dataclass
4
+ from typing import Any, Awaitable, Callable
5
+
6
+ from claude_agent_sdk import create_sdk_mcp_server, tool
7
+
8
+ from patchfeld.actions import ActionRegistry
9
+ from patchfeld.agents.manager import AgentManager
10
+ from patchfeld.config import ConfigStore, KeyBinding
11
+ from patchfeld.layout.local_widgets import validate_widget_source
12
+ from patchfeld.layout.registry import WidgetRegistry
13
+ from patchfeld.layout.spec import LayoutSpec
14
+ from patchfeld.orchestrator.tabs_tools import (
15
+ add_tab_handler,
16
+ close_tab_handler,
17
+ list_tabs_handler,
18
+ rename_tab_handler,
19
+ reorder_tabs_handler,
20
+ switch_tab_handler,
21
+ )
22
+ from patchfeld.persistence.atomic import write_text_atomic
23
+ from patchfeld.persistence.layouts_store import NamedLayoutsStore
24
+ from patchfeld.persistence.paths import local_widgets_dir
25
+ from patchfeld.persistence.themes_store import NamedThemesStore
26
+ from patchfeld.persistence.workspace_store import save_workspace
27
+ from patchfeld.theme.engine import _EXTRA_CSS_KEY, apply_theme, palette_from_textual_theme
28
+ from patchfeld.theme.spec import ThemeSpec
29
+
30
+
31
+ _VALID_WIDGET_NAME = re.compile(r"^[A-Za-z0-9_\-]+$")
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class _ToolSpec:
36
+ name: str
37
+ description: str
38
+ input_schema: dict
39
+ # build(manager) returns the async handler for this tool
40
+ build: Callable[[AgentManager], Callable[[dict], Awaitable[dict]]]
41
+
42
+
43
+ def _spawn_handler(manager: AgentManager):
44
+ async def spawn_agent(args: dict) -> dict:
45
+ agent_id = await manager.spawn(
46
+ name=args["name"],
47
+ prompt=args["prompt"],
48
+ cwd=args.get("cwd"),
49
+ allowed_tools=args.get("allowed_tools"),
50
+ )
51
+ return {
52
+ "content": [
53
+ {"type": "text", "text": f"Spawned agent {agent_id} ({args['name']})"}
54
+ ]
55
+ }
56
+ return spawn_agent
57
+
58
+
59
+ def _list_handler(manager: AgentManager):
60
+ async def list_agents(_args: dict) -> dict:
61
+ infos = [info.to_dict() for info in manager.list_infos()]
62
+ return {"content": [{"type": "text", "text": json.dumps(infos, indent=2)}]}
63
+ return list_agents
64
+
65
+
66
+ def _read_handler(manager: AgentManager):
67
+ async def read_agent_transcript(args: dict) -> dict:
68
+ entries = manager.read_transcript(args["agent_id"])
69
+ text = "\n".join(f"[{e.role}] {e.text}" for e in entries)
70
+ return {"content": [{"type": "text", "text": text}]}
71
+ return read_agent_transcript
72
+
73
+
74
+ def _send_handler(manager: AgentManager):
75
+ async def send_to_agent(args: dict) -> dict:
76
+ agent_id = args["agent_id"]
77
+ message = args["message"]
78
+ try:
79
+ await manager.send(agent_id, message)
80
+ return {
81
+ "content": [
82
+ {"type": "text", "text": f"Sent to {agent_id}: {message[:60]}"}
83
+ ]
84
+ }
85
+ except KeyError:
86
+ return {"content": [{"type": "text", "text": f"Unknown agent_id: {agent_id}"}]}
87
+ return send_to_agent
88
+
89
+
90
+ def _interrupt_handler(manager: AgentManager):
91
+ async def interrupt_agent(args: dict) -> dict:
92
+ agent_id = args["agent_id"]
93
+ if manager.get_session(agent_id) is None:
94
+ return {"content": [{"type": "text", "text": f"Unknown agent_id: {agent_id}"}]}
95
+ await manager.interrupt(agent_id)
96
+ return {"content": [{"type": "text", "text": f"Sent interrupt to {agent_id}."}]}
97
+ return interrupt_agent
98
+
99
+
100
+ def _kill_handler(manager: AgentManager):
101
+ async def kill_agent(args: dict) -> dict:
102
+ agent_id = args["agent_id"]
103
+ if manager.get_session(agent_id) is None:
104
+ return {"content": [{"type": "text", "text": f"Unknown agent_id: {agent_id}"}]}
105
+ await manager.kill(agent_id)
106
+ return {"content": [{"type": "text", "text": f"Killed agent {agent_id}."}]}
107
+ return kill_agent
108
+
109
+
110
+ def _respond_handler(manager: AgentManager):
111
+ async def respond_to_agent_request(args: dict) -> dict:
112
+ agent_id = args["agent_id"]
113
+ request_id = args["request_id"]
114
+ response = args["response"]
115
+ inbox = manager.get_inbox(agent_id)
116
+ if inbox is None:
117
+ return {
118
+ "content": [{"type": "text", "text": f"Unknown agent_id (no inbox): {agent_id}"}]
119
+ }
120
+ inbox.resolve(request_id, response)
121
+ return {
122
+ "content": [
123
+ {"type": "text", "text": f"Resolved request {request_id} for {agent_id}."}
124
+ ]
125
+ }
126
+ return respond_to_agent_request
127
+
128
+
129
+ def _set_layout_handler(apply_layout, widget_registry=None):
130
+ from patchfeld.layout.custom_widgets import register_custom_widget, CustomWidgetError
131
+
132
+ async def set_layout_tool(args: dict) -> dict:
133
+ try:
134
+ spec = LayoutSpec.model_validate(args["spec"])
135
+ except Exception as e:
136
+ return {"content": [{"type": "text", "text": f"Invalid LayoutSpec: {e}"}]}
137
+ # Register custom widgets BEFORE applying. If any source fails to
138
+ # exec or doesn't yield a Widget subclass, abort the apply atomically.
139
+ if spec.custom_widgets and widget_registry is not None:
140
+ for cw in spec.custom_widgets:
141
+ try:
142
+ register_custom_widget(widget_registry, cw.name, cw.source)
143
+ except CustomWidgetError as e:
144
+ return {
145
+ "content": [{
146
+ "type": "text",
147
+ "text": f"Custom widget {cw.name!r} error: {e}",
148
+ }]
149
+ }
150
+ try:
151
+ await apply_layout(spec, tab_id=args.get("tab_id"))
152
+ except Exception as e:
153
+ return {"content": [{"type": "text", "text": f"Apply error: {e}"}]}
154
+ return {"content": [{"type": "text", "text": "Layout applied."}]}
155
+ return set_layout_tool
156
+
157
+
158
+ def _save_layout_handler(layouts_store: NamedLayoutsStore, app=None):
159
+ async def save_layout_tool(args: dict) -> dict:
160
+ name = args["name"]
161
+ if "spec" in args:
162
+ try:
163
+ spec = LayoutSpec.model_validate(args["spec"])
164
+ except Exception as e:
165
+ return {"content": [{"type": "text", "text": f"Invalid LayoutSpec: {e}"}]}
166
+ elif app is not None:
167
+ tid = args.get("tab_id") or app._active_tab_id
168
+ ws = app._workspace
169
+ if ws is None or tid is None:
170
+ return {"content": [{"type": "text", "text": "No tab to save."}]}
171
+ tab = next((t for t in ws.tabs if t.id == tid), None)
172
+ if tab is None:
173
+ return {"content": [{"type": "text", "text": f"Unknown tab_id: {tid}"}]}
174
+ spec = tab.layout
175
+ else:
176
+ return {"content": [{"type": "text", "text": "Provide `spec` or call from an app."}]}
177
+ try:
178
+ layouts_store.save(name, spec)
179
+ except ValueError as e:
180
+ return {"content": [{"type": "text", "text": f"Invalid layout name: {e}"}]}
181
+ return {"content": [{"type": "text", "text": f"Saved layout {name!r}."}]}
182
+ return save_layout_tool
183
+
184
+
185
+ def _load_layout_handler(apply_layout, layouts_store: NamedLayoutsStore, app=None):
186
+ async def load_layout_tool(args: dict) -> dict:
187
+ name = args["name"]
188
+ spec = layouts_store.load(name)
189
+ if spec is None:
190
+ return {"content": [{"type": "text", "text": f"Layout not found: {name}"}]}
191
+ as_new_tab = bool(args.get("as_new_tab"))
192
+ if as_new_tab and app is not None:
193
+ try:
194
+ tab_id = await app.add_tab(args.get("title", name), spec, activate=True)
195
+ except Exception as e:
196
+ return {"content": [{"type": "text", "text": f"add_tab failed: {e}"}]}
197
+ return {"content": [{"type": "text",
198
+ "text": f"Loaded {name!r} into new tab {tab_id}."}]}
199
+ try:
200
+ await apply_layout(spec, layout_name=name, tab_id=args.get("tab_id"))
201
+ except Exception as e:
202
+ return {"content": [{"type": "text", "text": f"Apply error: {e}"}]}
203
+ return {"content": [{"type": "text", "text": f"Loaded layout {name!r}."}]}
204
+ return load_layout_tool
205
+
206
+
207
+ def _list_layouts_handler(layouts_store: NamedLayoutsStore):
208
+ async def list_layouts_tool(_args: dict) -> dict:
209
+ names = layouts_store.list()
210
+ text = json.dumps(names)
211
+ return {"content": [{"type": "text", "text": text}]}
212
+ return list_layouts_tool
213
+
214
+
215
+ def _bind_key_handler(config_store: ConfigStore, actions: ActionRegistry, rebind):
216
+ async def bind_key_tool(args: dict) -> dict:
217
+ key = args["key"]
218
+ action = args["action"]
219
+ bind_args = args.get("args", {})
220
+ try:
221
+ actions.get(action)
222
+ except KeyError:
223
+ return {"content": [{"type": "text", "text": f"Unknown action: {action}"}]}
224
+ cfg = config_store.load()
225
+ cfg.bindings[key] = KeyBinding(action=action, args=dict(bind_args))
226
+ config_store.save(cfg)
227
+ if rebind is not None:
228
+ rebind()
229
+ return {"content": [{"type": "text", "text": f"Bound {key!r} → {action}."}]}
230
+ return bind_key_tool
231
+
232
+
233
+ def _unbind_key_handler(config_store: ConfigStore, rebind):
234
+ async def unbind_key_tool(args: dict) -> dict:
235
+ key = args["key"]
236
+ cfg = config_store.load()
237
+ if key in cfg.bindings:
238
+ del cfg.bindings[key]
239
+ config_store.save(cfg)
240
+ if rebind is not None:
241
+ rebind()
242
+ return {"content": [{"type": "text", "text": f"Unbound {key!r}."}]}
243
+ return {"content": [{"type": "text", "text": f"No binding for {key!r}."}]}
244
+ return unbind_key_tool
245
+
246
+
247
+ def _set_config_handler(config_store: ConfigStore):
248
+ async def set_config_tool(args: dict) -> dict:
249
+ path = args["path"]
250
+ value = args["value"]
251
+ cfg = config_store.load()
252
+ try:
253
+ cfg.set_path(path, value)
254
+ except KeyError:
255
+ return {"content": [{"type": "text", "text": f"Unknown config path: {path}"}]}
256
+ config_store.save(cfg)
257
+ return {"content": [{"type": "text", "text": f"Set {path} = {value!r}."}]}
258
+ return set_config_tool
259
+
260
+
261
+ def _get_config_handler(config_store: ConfigStore):
262
+ async def get_config_tool(args: dict) -> dict:
263
+ path = args["path"]
264
+ cfg = config_store.load()
265
+ try:
266
+ value = cfg.get_path(path)
267
+ except KeyError:
268
+ return {"content": [{"type": "text", "text": f"Unknown config path: {path}"}]}
269
+ return {"content": [{"type": "text", "text": json.dumps(value)}]}
270
+ return get_config_tool
271
+
272
+
273
+ def _list_actions_handler(actions: ActionRegistry):
274
+ async def list_actions_tool(_args: dict) -> dict:
275
+ out = [
276
+ {"name": s.name, "description": s.description, "args_schema": list(s.args_schema.keys())}
277
+ for s in actions.list()
278
+ ]
279
+ return {"content": [{"type": "text", "text": json.dumps(out, indent=2)}]}
280
+ return list_actions_tool
281
+
282
+
283
+ def _list_bindings_handler(config_store: ConfigStore):
284
+ async def list_bindings_tool(_args: dict) -> dict:
285
+ cfg = config_store.load()
286
+ out = [
287
+ {"key": k, "action": b.action, "args": b.args}
288
+ for k, b in sorted(cfg.bindings.items())
289
+ ]
290
+ return {"content": [{"type": "text", "text": json.dumps(out, indent=2)}]}
291
+ return list_bindings_tool
292
+
293
+
294
+ def _list_widgets_handler(registry: WidgetRegistry, outcomes_provider=None):
295
+ """outcomes_provider: optional zero-arg callable returning a list of
296
+ LoadOutcome (or compatible dicts). When provided, failed outcomes are
297
+ emitted under `errors`. When None, `errors` is `[]`.
298
+ """
299
+ async def list_widgets_tool(_args: dict) -> dict:
300
+ widgets = []
301
+ for info in registry.describe_all():
302
+ widgets.append({
303
+ "name": info.name,
304
+ "description": info.description,
305
+ "props_schema": {
306
+ k: getattr(v, "__name__", str(v))
307
+ for k, v in info.props_schema.items()
308
+ },
309
+ "source": info.source,
310
+ })
311
+ errors = []
312
+ if outcomes_provider is not None:
313
+ for o in outcomes_provider():
314
+ if getattr(o, "status", "ok") == "ok":
315
+ continue
316
+ errors.append({
317
+ "path": str(getattr(o, "path", "")),
318
+ "name": getattr(o, "name", ""),
319
+ "status": getattr(o, "status", ""),
320
+ "error": getattr(o, "error", None),
321
+ })
322
+ envelope = {"widgets": widgets, "errors": errors}
323
+ return {"content": [{"type": "text", "text": json.dumps(envelope, indent=2)}]}
324
+ return list_widgets_tool
325
+
326
+
327
+ def _get_layout_handler(current_layout, widget_registry: WidgetRegistry, app=None):
328
+ from patchfeld.layout.titles import populate_effective_titles
329
+
330
+ async def get_layout_tool(args: dict) -> dict:
331
+ target_tab_id = (args or {}).get("tab_id")
332
+ spec = None
333
+ tab_title = None
334
+ tab_id = None
335
+ if app is not None:
336
+ ws = getattr(app, "_workspace", None)
337
+ tid = target_tab_id or getattr(app, "_active_tab_id", None)
338
+ if ws is not None and tid is not None:
339
+ tab = next((t for t in ws.tabs if t.id == tid), None)
340
+ if tab is not None:
341
+ spec = tab.layout
342
+ tab_id = tab.id
343
+ tab_title = tab.title
344
+ if spec is None:
345
+ spec = current_layout() if current_layout is not None else None
346
+ if spec is None:
347
+ return {"content": [{"type": "text", "text": "No layout applied yet."}]}
348
+ dumped = spec.model_dump(mode="json")
349
+ try:
350
+ populate_effective_titles(dumped["layout"], widget_registry)
351
+ except Exception:
352
+ pass # Titles are advisory; never block the dump.
353
+ out = {"tab_id": tab_id, "tab_title": tab_title, "spec": dumped}
354
+ return {"content": [{"type": "text", "text": json.dumps(out, indent=2)}]}
355
+
356
+ return get_layout_tool
357
+
358
+
359
+ def _set_theme_handler(app):
360
+ async def set_theme_tool(args: dict) -> dict:
361
+ try:
362
+ spec = ThemeSpec.model_validate(args["spec"])
363
+ except Exception as e:
364
+ return {"content": [{"type": "text", "text": f"Invalid ThemeSpec: {e}"}]}
365
+ try:
366
+ await apply_theme(app, spec, theme_name="<inline>")
367
+ except Exception as e:
368
+ return {"content": [{"type": "text", "text": f"Apply error: {e}"}]}
369
+ return {"content": [{"type": "text", "text": "Theme applied."}]}
370
+ return set_theme_tool
371
+
372
+
373
+ def _save_theme_handler(themes_store: NamedThemesStore, app):
374
+ async def save_theme_tool(args: dict) -> dict:
375
+ name = args["name"]
376
+ if "spec" in args:
377
+ try:
378
+ spec = ThemeSpec.model_validate(args["spec"])
379
+ except Exception as e:
380
+ return {"content": [{"type": "text", "text": f"Invalid ThemeSpec: {e}"}]}
381
+ else:
382
+ try:
383
+ palette = palette_from_textual_theme(app.current_theme)
384
+ except Exception as e:
385
+ return {"content": [{"type": "text",
386
+ "text": f"Could not snapshot active theme: {e}"}]}
387
+ extra = getattr(app, "_active_theme_extra_css", "") or ""
388
+ spec = ThemeSpec(palette=palette, extra_css=extra)
389
+ try:
390
+ themes_store.save(name, spec)
391
+ except ValueError as e:
392
+ return {"content": [{"type": "text", "text": f"Invalid theme name: {e}"}]}
393
+ return {"content": [{"type": "text", "text": f"Saved theme {name!r}."}]}
394
+ return save_theme_tool
395
+
396
+
397
+ def _load_theme_handler(themes_store: NamedThemesStore, app, config_store=None):
398
+ async def load_theme_tool(args: dict) -> dict:
399
+ name = args["name"]
400
+ persist = bool(args.get("persist", True))
401
+ scope = args.get("scope", "global")
402
+ if scope not in ("global", "project"):
403
+ return {"content": [{"type": "text",
404
+ "text": f"Invalid scope: {scope!r} (use 'global' or 'project')"}]}
405
+ # 1. Try saved store.
406
+ spec = themes_store.load(name)
407
+ if spec is not None:
408
+ try:
409
+ await apply_theme(app, spec, theme_name=name)
410
+ except Exception as e:
411
+ return {"content": [{"type": "text", "text": f"Apply error: {e}"}]}
412
+ else:
413
+ # 2. Fall through to Textual built-ins.
414
+ try:
415
+ available = app.available_themes
416
+ except Exception:
417
+ available = {}
418
+ if name not in available:
419
+ return {"content": [{"type": "text", "text": f"Theme not found: {name}"}]}
420
+ # Built-in pass-through: clear our extra_css source, set theme directly.
421
+ if _EXTRA_CSS_KEY in app.stylesheet.source:
422
+ del app.stylesheet.source[_EXTRA_CSS_KEY]
423
+ app._active_theme_extra_css = ""
424
+ app.theme = name
425
+ try:
426
+ app.refresh_css()
427
+ except Exception:
428
+ pass
429
+
430
+ # 3. Persist active-theme pointer if asked.
431
+ warnings: list[str] = []
432
+ if persist:
433
+ if scope == "global":
434
+ if config_store is not None:
435
+ cfg = config_store.load()
436
+ cfg.ui.active_theme = name
437
+ config_store.save(cfg)
438
+ else:
439
+ warnings.append("persist requested but no config_store available")
440
+ elif scope == "project":
441
+ ws = getattr(app, "_workspace", None)
442
+ if ws is not None:
443
+ ws = ws.model_copy(update={"active_theme": name})
444
+ app._workspace = ws
445
+ save_workspace(app.cwd, ws)
446
+ else:
447
+ warnings.append("persist requested but no workspace available")
448
+
449
+ msg = f"Loaded theme {name!r}."
450
+ if warnings:
451
+ msg += " Warning: " + "; ".join(warnings) + "."
452
+ return {"content": [{"type": "text", "text": msg}]}
453
+ return load_theme_tool
454
+
455
+
456
+ def _list_themes_handler(themes_store: NamedThemesStore, app):
457
+ async def list_themes_tool(_args: dict) -> dict:
458
+ saved = themes_store.list()
459
+ try:
460
+ builtin = sorted(
461
+ n for n in app.available_themes.keys()
462
+ if not n.startswith("patchfeld:")
463
+ )
464
+ except Exception:
465
+ builtin = []
466
+ active = getattr(app, "theme", None) or ""
467
+ # Strip the "patchfeld:" prefix from active for user-facing display
468
+ # so a saved theme named "alpha" reads back as "alpha".
469
+ if active.startswith("patchfeld:"):
470
+ active_display = active[len("patchfeld:"):]
471
+ else:
472
+ active_display = active
473
+ payload = {"saved": saved, "builtin": builtin, "active": active_display}
474
+ return {"content": [{"type": "text", "text": json.dumps(payload)}]}
475
+ return list_themes_tool
476
+
477
+
478
+ def _get_theme_handler(themes_store: NamedThemesStore, app):
479
+ async def get_theme_tool(args: dict) -> dict:
480
+ name = (args or {}).get("name")
481
+ if name:
482
+ spec = themes_store.load(name)
483
+ if spec is None:
484
+ return {"content": [{"type": "text", "text": f"Theme not found: {name}"}]}
485
+ return {"content": [{"type": "text", "text": json.dumps(spec.model_dump(mode="json"))}]}
486
+ # No name → snapshot the active theme.
487
+ try:
488
+ palette = palette_from_textual_theme(app.current_theme).model_dump(mode="json")
489
+ except Exception as e:
490
+ return {"content": [{"type": "text", "text": f"Cannot read active theme: {e}"}]}
491
+ active = getattr(app, "theme", "") or ""
492
+ if active.startswith("patchfeld:"):
493
+ active = active[len("patchfeld:"):]
494
+ extra = getattr(app, "_active_theme_extra_css", "") or ""
495
+ payload = {"name": active, "palette": palette, "extra_css": extra}
496
+ return {"content": [{"type": "text", "text": json.dumps(payload)}]}
497
+ return get_theme_tool
498
+
499
+
500
+ _SPECS: list[_ToolSpec] = [
501
+ _ToolSpec(
502
+ name="spawn_agent",
503
+ description=(
504
+ "Spawn a new Claude Code child agent. `name` is a short label "
505
+ "for the table; `prompt` is the initial task. Optional `cwd` "
506
+ "overrides the working directory; optional `allowed_tools` is a "
507
+ "list of tool names to whitelist for this child (defaults to "
508
+ "inheriting the user's settings.json)."
509
+ ),
510
+ # Use a full JSON Schema dict so the SDK's pass-through path is
511
+ # triggered (it checks for "type" + "properties" keys). This lets us
512
+ # mark cwd / allowed_tools as optional (absent from "required") while
513
+ # still advertising them to the orchestrator AI.
514
+ input_schema={
515
+ "type": "object",
516
+ "properties": {
517
+ "name": {"type": "string"},
518
+ "prompt": {"type": "string"},
519
+ "cwd": {"type": "string"},
520
+ "allowed_tools": {
521
+ "type": "array",
522
+ "items": {"type": "string"},
523
+ },
524
+ },
525
+ "required": ["name", "prompt"],
526
+ },
527
+ build=_spawn_handler,
528
+ ),
529
+ _ToolSpec(
530
+ name="list_agents",
531
+ description="List all currently registered agents and their states.",
532
+ input_schema={},
533
+ build=_list_handler,
534
+ ),
535
+ _ToolSpec(
536
+ name="read_agent_transcript",
537
+ description="Read the full transcript of an agent by id.",
538
+ input_schema={"agent_id": str},
539
+ build=_read_handler,
540
+ ),
541
+ _ToolSpec(
542
+ name="send_to_agent",
543
+ description=(
544
+ "Send a follow-up message to an existing agent. The agent will "
545
+ "process it as a new turn."
546
+ ),
547
+ input_schema={"agent_id": str, "message": str},
548
+ build=_send_handler,
549
+ ),
550
+ _ToolSpec(
551
+ name="interrupt_agent",
552
+ description="Interrupt the agent's current generation, if any.",
553
+ input_schema={"agent_id": str},
554
+ build=_interrupt_handler,
555
+ ),
556
+ _ToolSpec(
557
+ name="kill_agent",
558
+ description="Stop and remove an agent session.",
559
+ input_schema={"agent_id": str},
560
+ build=_kill_handler,
561
+ ),
562
+ _ToolSpec(
563
+ name="respond_to_agent_request",
564
+ description=(
565
+ "Respond to an agent's pending ask_orchestrator request, "
566
+ "identified by request_id."
567
+ ),
568
+ input_schema={"agent_id": str, "request_id": str, "response": str},
569
+ build=_respond_handler,
570
+ ),
571
+ ]
572
+
573
+
574
+ def _change_cwd_handler(app):
575
+ async def change_cwd_tool(args: dict) -> dict:
576
+ path = args.get("path")
577
+ if not path:
578
+ return {"content": [{"type": "text", "text": "path is required"}]}
579
+ result = await app.change_cwd(path)
580
+ if "error" in result:
581
+ return {"content": [{"type": "text",
582
+ "text": f"change_cwd error: {result}"}]}
583
+ if result.get("unchanged"):
584
+ return {"content": [{"type": "text", "text": "cwd unchanged."}]}
585
+ return {"content": [{"type": "text",
586
+ "text": f"Re-rooted at {result['changed']}."}]}
587
+ return change_cwd_tool
588
+
589
+
590
+ def _save_widget_handler(app):
591
+ async def save_widget_tool(args: dict) -> dict:
592
+ name = (args.get("name") or "").strip()
593
+ source = args.get("source") or ""
594
+ if not _VALID_WIDGET_NAME.fullmatch(name):
595
+ return {"content": [{"type": "text", "text":
596
+ f"Invalid widget name: {name!r} (allowed: letters, digits, _, -)"}]}
597
+
598
+ registry = getattr(app, "registry", None)
599
+ if registry is None:
600
+ return {"content": [{"type": "text",
601
+ "text": "save_widget unavailable: no registry on app."}]}
602
+
603
+ existing = registry._infos.get(name)
604
+ if existing is not None and existing.source == "builtin":
605
+ return {"content": [{"type": "text", "text":
606
+ f"Cannot save widget {name!r}: name reserved by built-in registry."}]}
607
+
608
+ cls, meta, err = validate_widget_source(name, source)
609
+ if cls is None:
610
+ return {"content": [{"type": "text",
611
+ "text": f"Cannot save widget {name!r}: {err}"}]}
612
+
613
+ widgets_dir = local_widgets_dir(global_dir=app._global_dir)
614
+ path = widgets_dir / f"{name}.py"
615
+ try:
616
+ write_text_atomic(path, source)
617
+ except OSError as e:
618
+ return {"content": [{"type": "text",
619
+ "text": f"Cannot save widget {name!r}: write failed: {e}"}]}
620
+
621
+ registry.unregister(name)
622
+ registry.register(
623
+ name, cls,
624
+ description=meta.get("description", ""),
625
+ props_schema=dict(meta.get("props_schema") or {}),
626
+ source="local",
627
+ )
628
+
629
+ return {"content": [{"type": "text", "text":
630
+ f"Saved widget {name!r} to {path}. Registered live; "
631
+ f"use it in set_layout immediately."}]}
632
+ return save_widget_tool
633
+
634
+
635
+ def build_orchestrator_tools(
636
+ manager: AgentManager,
637
+ *,
638
+ apply_layout=None,
639
+ layouts_store: NamedLayoutsStore | None = None,
640
+ themes_store: NamedThemesStore | None = None,
641
+ config_store: ConfigStore | None = None,
642
+ actions: ActionRegistry | None = None,
643
+ rebind_keys=None,
644
+ widget_registry: WidgetRegistry | None = None,
645
+ current_layout=None,
646
+ app=None,
647
+ ):
648
+ """Return a dict {tool_name: async_handler} for unit testing.
649
+
650
+ apply_layout: async callable (spec, *, layout_name=None) -> None applying a
651
+ LayoutSpec to the live UI. If None, set_layout / load_layout are omitted.
652
+ layouts_store: NamedLayoutsStore for save/load/list. If None, the
653
+ save/load/list tools are omitted.
654
+ config_store + actions: if both provided, config/keybinding tools are added.
655
+ rebind_keys: optional callable invoked after any keybinding change.
656
+ widget_registry: if provided, a list_widgets tool is added.
657
+ app: if provided, tab management tools (add_tab, close_tab, switch_tab,
658
+ list_tabs) are registered.
659
+ """
660
+ handlers: dict = {}
661
+ for spec in _SPECS:
662
+ handlers[spec.name] = spec.build(manager)
663
+ if apply_layout is not None and layouts_store is not None:
664
+ handlers["set_layout"] = _set_layout_handler(apply_layout, widget_registry)
665
+ handlers["save_layout"] = _save_layout_handler(layouts_store, app=app)
666
+ handlers["load_layout"] = _load_layout_handler(apply_layout, layouts_store, app=app)
667
+ handlers["list_layouts"] = _list_layouts_handler(layouts_store)
668
+ if config_store is not None and actions is not None:
669
+ handlers["bind_key"] = _bind_key_handler(config_store, actions, rebind_keys)
670
+ handlers["unbind_key"] = _unbind_key_handler(config_store, rebind_keys)
671
+ handlers["set_config"] = _set_config_handler(config_store)
672
+ handlers["get_config"] = _get_config_handler(config_store)
673
+ handlers["list_actions"] = _list_actions_handler(actions)
674
+ handlers["list_bindings"] = _list_bindings_handler(config_store)
675
+ if widget_registry is not None:
676
+ provider = (lambda: getattr(app, "_local_widget_outcomes", []))
677
+ handlers["list_widgets"] = _list_widgets_handler(widget_registry, provider)
678
+ if widget_registry is not None and current_layout is not None:
679
+ handlers["get_layout"] = _get_layout_handler(current_layout, widget_registry, app=app)
680
+ if themes_store is not None and app is not None:
681
+ handlers["set_theme"] = _set_theme_handler(app)
682
+ handlers["save_theme"] = _save_theme_handler(themes_store, app)
683
+ handlers["load_theme"] = _load_theme_handler(
684
+ themes_store, app, config_store=config_store,
685
+ )
686
+ handlers["list_themes"] = _list_themes_handler(themes_store, app)
687
+ handlers["get_theme"] = _get_theme_handler(themes_store, app)
688
+ if app is not None:
689
+ handlers["add_tab"] = add_tab_handler(app)
690
+ handlers["close_tab"] = close_tab_handler(app)
691
+ handlers["switch_tab"] = switch_tab_handler(app)
692
+ handlers["list_tabs"] = list_tabs_handler(app)
693
+ handlers["rename_tab"] = rename_tab_handler(app)
694
+ handlers["reorder_tabs"] = reorder_tabs_handler(app)
695
+ handlers["change_cwd"] = _change_cwd_handler(app)
696
+ handlers["save_widget"] = _save_widget_handler(app)
697
+ return handlers
698
+
699
+
700
+ def build_orchestrator_mcp_server(
701
+ manager: AgentManager,
702
+ *,
703
+ apply_layout=None,
704
+ layouts_store: NamedLayoutsStore | None = None,
705
+ themes_store: NamedThemesStore | None = None,
706
+ config_store: ConfigStore | None = None,
707
+ actions: ActionRegistry | None = None,
708
+ rebind_keys=None,
709
+ widget_registry: WidgetRegistry | None = None,
710
+ current_layout=None,
711
+ app=None,
712
+ ):
713
+ sdk_tools = []
714
+ for spec in _SPECS:
715
+ handler = spec.build(manager)
716
+ decorated = tool(spec.name, spec.description, spec.input_schema)(handler)
717
+ sdk_tools.append(decorated)
718
+ if apply_layout is not None and layouts_store is not None:
719
+ layout_specs = [
720
+ (
721
+ "set_layout",
722
+ "Edit the **active** tab's layout (or pass `tab_id` to "
723
+ "target a specific tab). Use add_tab to create new tabs "
724
+ "instead of inserting OrchestratorChat panels. Each panel "
725
+ "may set an optional `title` field; the user references "
726
+ "panels by title in chat. Call get_layout first to "
727
+ "discover effective titles. Spec format supports a new "
728
+ "node type `{type: 'tabs', children: [Panel, ...], "
729
+ "active: '<panel_id>'}` for panel-level tabs (each tab "
730
+ "holds exactly one widget). "
731
+ "If `spec.custom_widgets` is present, each entry's `source` "
732
+ "string is **exec'd in-process with full Python privileges** "
733
+ "to register a new Widget class before the layout is applied. "
734
+ "Only ship `custom_widgets` source you have personally "
735
+ "authored — anything you exec here can read files, hit the "
736
+ "network, and execute arbitrary code with the user's "
737
+ "permissions. The built-in widgets (list_widgets) are safer.",
738
+ {"spec": dict, "tab_id": str},
739
+ _set_layout_handler(apply_layout, widget_registry),
740
+ ),
741
+ (
742
+ "save_layout",
743
+ "Save a LayoutSpec under a name in ~/.config/patchfeld/layouts/. "
744
+ "If `spec` is omitted, saves the active tab's current layout "
745
+ "(or the tab named by `tab_id`).",
746
+ {"name": str, "spec": dict, "tab_id": str},
747
+ _save_layout_handler(layouts_store, app=app),
748
+ ),
749
+ (
750
+ "load_layout",
751
+ "Load a saved layout by name and apply it. By default it "
752
+ "replaces the active tab's spec. Pass `tab_id` to target a "
753
+ "specific tab. Pass `as_new_tab: true` to create a new tab "
754
+ "seeded from the named layout instead (use `title` to label "
755
+ "the new tab; defaults to the layout name).",
756
+ {"name": str, "tab_id": str, "as_new_tab": bool, "title": str},
757
+ _load_layout_handler(apply_layout, layouts_store, app=app),
758
+ ),
759
+ (
760
+ "list_layouts",
761
+ "List the names of all saved layouts.",
762
+ {},
763
+ _list_layouts_handler(layouts_store),
764
+ ),
765
+ ]
766
+ for name, desc, schema, handler in layout_specs:
767
+ sdk_tools.append(tool(name, desc, schema)(handler))
768
+ if themes_store is not None and app is not None:
769
+ theme_specs = [
770
+ (
771
+ "set_theme",
772
+ "Apply a ThemeSpec to the live app. The spec is "
773
+ "{ palette: {primary, secondary, warning, error, success, accent, "
774
+ "foreground, background, surface, panel, boost, dark, "
775
+ "luminosity_spread, text_alpha, variables}, extra_css: str }. "
776
+ "Color strings follow Textual's syntax (#rrggbb or named). "
777
+ "If `extra_css` is present, it is parsed at app scope; bad "
778
+ "CSS is rejected before the palette change. Only ship "
779
+ "`extra_css` you have personally authored — CSS can hide "
780
+ "chrome, fake widgets, or break input visibility. Does NOT "
781
+ "persist; use save_theme + load_theme for that.",
782
+ {"spec": dict},
783
+ _set_theme_handler(app),
784
+ ),
785
+ (
786
+ "save_theme",
787
+ "Save a ThemeSpec to ~/.config/patchfeld/themes/<name>.json. "
788
+ "If `spec` is omitted, snapshots the currently-active palette "
789
+ "and the last applied extra_css. Use this to capture the "
790
+ "live look as a named theme.",
791
+ {"name": str, "spec": dict},
792
+ _save_theme_handler(themes_store, app),
793
+ ),
794
+ (
795
+ "load_theme",
796
+ "Load a saved theme by name and apply it. Falls through to "
797
+ "Textual built-ins (textual-dark, nord, gruvbox, dracula, "
798
+ "catppuccin-*, …) if the name is not in the saved store. "
799
+ "When `persist` (default true) the active-theme pointer is "
800
+ "written: `scope='global'` writes ~/.config/patchfeld/config.toml "
801
+ "ui.active_theme; `scope='project'` writes workspace.json's "
802
+ "active_theme. Default scope is 'global'.",
803
+ {"name": str, "persist": bool, "scope": str},
804
+ _load_theme_handler(themes_store, app, config_store=config_store),
805
+ ),
806
+ (
807
+ "list_themes",
808
+ "Return {saved, builtin, active}. `saved` is the user's "
809
+ "named themes; `builtin` is Textual's built-in themes "
810
+ "(read-only); `active` is the current theme name (without "
811
+ "the internal patchfeld: prefix).",
812
+ {},
813
+ _list_themes_handler(themes_store, app),
814
+ ),
815
+ (
816
+ "get_theme",
817
+ "Return a saved theme's full spec when `name` is given. "
818
+ "Without `name`, returns the active theme as "
819
+ "{name, palette, extra_css}. Pass the result back through "
820
+ "set_theme to apply edits.",
821
+ {"name": str},
822
+ _get_theme_handler(themes_store, app),
823
+ ),
824
+ ]
825
+ for name, desc, schema, handler in theme_specs:
826
+ sdk_tools.append(tool(name, desc, schema)(handler))
827
+ if config_store is not None and actions is not None:
828
+ config_specs = [
829
+ (
830
+ "bind_key",
831
+ "Bind a key (e.g., 'ctrl+x', '~') to a registered action. "
832
+ "Optional `args` dict is passed to the action when invoked.",
833
+ {"key": str, "action": str},
834
+ _bind_key_handler(config_store, actions, rebind_keys),
835
+ ),
836
+ (
837
+ "unbind_key",
838
+ "Remove the binding for the given key.",
839
+ {"key": str},
840
+ _unbind_key_handler(config_store, rebind_keys),
841
+ ),
842
+ (
843
+ "set_config",
844
+ "Set a config value by dotted path (e.g., 'ui.active_theme').",
845
+ {"path": str, "value": str},
846
+ _set_config_handler(config_store),
847
+ ),
848
+ (
849
+ "get_config",
850
+ "Read a config value by dotted path. Returns the value as JSON.",
851
+ {"path": str},
852
+ _get_config_handler(config_store),
853
+ ),
854
+ (
855
+ "list_actions",
856
+ "List all registered keybinding actions.",
857
+ {},
858
+ _list_actions_handler(actions),
859
+ ),
860
+ (
861
+ "list_bindings",
862
+ "List all current keybindings.",
863
+ {},
864
+ _list_bindings_handler(config_store),
865
+ ),
866
+ ]
867
+ for name, desc, schema, handler in config_specs:
868
+ sdk_tools.append(tool(name, desc, schema)(handler))
869
+ if widget_registry is not None:
870
+ provider = (lambda: getattr(app, "_local_widget_outcomes", []))
871
+ sdk_tools.append(tool(
872
+ "list_widgets",
873
+ "List all widgets registered in the layout registry. Returns "
874
+ "{widgets: [{name, description, props_schema, source}], "
875
+ "errors: [{path, name, status, error}]}. `source` is one of "
876
+ "'builtin' (compiled-in), 'local' (loaded from "
877
+ "~/.config/patchfeld/widgets/), or 'inline' (registered via a "
878
+ "LayoutSpec.custom_widgets source). `errors` lists widget files "
879
+ "in the local dir that failed to load.",
880
+ {},
881
+ )(_list_widgets_handler(widget_registry, provider)))
882
+ if widget_registry is not None and current_layout is not None:
883
+ sdk_tools.append(tool(
884
+ "get_layout",
885
+ "Returns the active tab's LayoutSpec as JSON, alongside `tab_id` "
886
+ "and `tab_title`. Each panel's `title` field is populated to its "
887
+ "effective on-screen value. Pass `tab_id` to inspect a specific "
888
+ "tab. Pass the `spec` field's value back through `set_layout` to "
889
+ "edit the tab.",
890
+ {"tab_id": str},
891
+ )(_get_layout_handler(current_layout, widget_registry, app=app)))
892
+ if app is not None:
893
+ sdk_tools.append(tool(
894
+ "add_tab",
895
+ "Create a new app-level tab. `title` is the user-facing label "
896
+ "on the tab strip. Optional `layout` may be a LayoutSpec dict, "
897
+ "the name of a saved layout (resolved from the named-layouts "
898
+ "store), or omitted (a default seed is used). Optional "
899
+ "`activate` (default true) makes the new tab the active one. "
900
+ "Returns the new tab id.",
901
+ {"title": str, "layout": dict, "activate": bool},
902
+ )(add_tab_handler(app)))
903
+ sdk_tools.append(tool(
904
+ "close_tab",
905
+ "Close the tab with the given id. Refuses if it would leave "
906
+ "the workspace with zero OrchestratorChat panels (returns a "
907
+ "structured error so you can add chat to another tab first). "
908
+ "Refuses if it's the last tab.",
909
+ {"tab_id": str},
910
+ )(close_tab_handler(app)))
911
+ sdk_tools.append(tool(
912
+ "switch_tab",
913
+ "Make the tab with the given id the active one.",
914
+ {"tab_id": str},
915
+ )(switch_tab_handler(app)))
916
+ sdk_tools.append(tool(
917
+ "list_tabs",
918
+ "List all tabs with id, title, active flag, has_chat flag, "
919
+ "and the list of panel ids contained in each tab.",
920
+ {},
921
+ )(list_tabs_handler(app)))
922
+ sdk_tools.append(tool(
923
+ "rename_tab",
924
+ "Rename an existing tab. `tab_id` identifies the tab; `title` "
925
+ "is the new user-facing label shown in the tab strip. The "
926
+ "underlying widgets are not re-mounted, so panel state is "
927
+ "preserved.",
928
+ {"tab_id": str, "title": str},
929
+ )(rename_tab_handler(app)))
930
+ sdk_tools.append(tool(
931
+ "reorder_tabs",
932
+ "Rearrange the tab strip. `tab_ids` must be a permutation of "
933
+ "the existing tab ids — every current id must appear exactly "
934
+ "once. The active tab stays active (just at a new position). "
935
+ "Widget state is preserved across the reorder.",
936
+ {"tab_ids": list},
937
+ )(reorder_tabs_handler(app)))
938
+ sdk_tools.append(tool(
939
+ "change_cwd",
940
+ "Re-root the workspace at a new working directory. `path` is "
941
+ "expanded for `~` and resolved to absolute. Refuses if any "
942
+ "child agents are still running (kill or wait first). On "
943
+ "success, the previous workspace.json is saved at the OLD cwd, "
944
+ "the orchestrator session is reset, and the new cwd's "
945
+ "workspace.json is loaded (or seeded from the dashboard).",
946
+ {"path": str},
947
+ )(_change_cwd_handler(app)))
948
+ sdk_tools.append(tool(
949
+ "save_widget",
950
+ "Persist a custom Textual widget to "
951
+ "~/.config/patchfeld/widgets/<name>.py and register it into the "
952
+ "live registry so it's available immediately for `set_layout` "
953
+ "and visible in the next `list_widgets` call (with "
954
+ "`source: \"local\"`). `name` is the widget's registered name "
955
+ "(letters, digits, _, -); the file is written as `<name>.py`. "
956
+ "`source` is the full Python source for the widget — including "
957
+ "imports and an OPTIONAL module-level `__patchfeld_widget__ = "
958
+ "{\"name\": ..., \"description\": ..., \"props_schema\": {...}}` "
959
+ "block to supply metadata visible in `list_widgets`. Use "
960
+ "`WIDGET_CLASS = <class>` or include the metadata's `entry_point` "
961
+ "field if your source defines more than one Widget subclass. "
962
+ "**Trust note: the source is exec'd in-process during validation "
963
+ "AND on every subsequent app start with full Python privileges. "
964
+ "Only save source you have personally authored.** On failure "
965
+ "(import error, no Widget subclass, builtin name collision, "
966
+ "invalid name) returns an error string in the response and does "
967
+ "NOT write to disk; do NOT poll `list_widgets`.errors looking "
968
+ "for the failure — that array reflects startup-time discovery "
969
+ "only, not save_widget rejections.",
970
+ {"name": str, "source": str},
971
+ )(_save_widget_handler(app)))
972
+ return create_sdk_mcp_server(
973
+ name="patchfeld_orchestrator",
974
+ version="1.0.0",
975
+ tools=sdk_tools,
976
+ )