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,785 @@
1
+ import asyncio
2
+ import logging
3
+ import re
4
+ import time
5
+ import uuid
6
+ from pathlib import Path
7
+ from typing import Callable
8
+
9
+ from claude_agent_sdk import (
10
+ AssistantMessage,
11
+ CanUseTool,
12
+ ClaudeAgentOptions,
13
+ PermissionResultAllow,
14
+ PermissionResultDeny,
15
+ TextBlock,
16
+ ToolPermissionContext,
17
+ query as sdk_query,
18
+ )
19
+ from claude_agent_sdk.types import SystemPromptPreset
20
+
21
+ from patchfeld.agents.manager import AgentManager
22
+ from patchfeld.agents.permission_grants import PermissionGrants
23
+ from patchfeld.agents.permission_inbox import PermissionInbox
24
+ from patchfeld.agents.sdk_adapter import RealSDKAdapter, SDKAdapter
25
+ from patchfeld.agents.session import AgentSession
26
+ from patchfeld.agents.state import AgentInfo
27
+ from patchfeld.events import (
28
+ AgentMessageAppended,
29
+ AgentNotifiedOrchestrator,
30
+ AgentRequestedUserInput,
31
+ AgentTokensTouched,
32
+ EventBus,
33
+ OpenResumePicker,
34
+ OrchestratorReply,
35
+ OrchestratorSessionSwitched,
36
+ PermissionRequested,
37
+ PermissionResolved,
38
+ UserMessageToOrchestrator,
39
+ )
40
+
41
+ from patchfeld.orchestrator.tools import build_orchestrator_mcp_server
42
+ from patchfeld.persistence.orchestrator_sessions import (
43
+ OrchestratorSessionEntry,
44
+ OrchestratorSessionsIndex,
45
+ )
46
+ from patchfeld.persistence.paths import orchestrator_session_transcript_path
47
+ from patchfeld.persistence.transcript_store import AgentTranscript
48
+
49
+ log = logging.getLogger(__name__)
50
+
51
+ _RESET_RE = re.compile(r"^/reset(?:\s|$)")
52
+ _RESUME_BARE_RE = re.compile(r"^/resume\s*$")
53
+ _RESUME_ID_RE = re.compile(r"^/resume\s+(\S+)\s*$")
54
+ # /rename <title> — current session
55
+ # /rename <session_id> <title> — specific session (id is non-space; title is rest)
56
+ _RENAME_RE = re.compile(r"^/rename(?:\s+(.*))?$")
57
+ _HELP_RE = re.compile(r"^/help\s*$")
58
+ _CD_RE = re.compile(r"^/cd\s+(.+?)\s*$")
59
+ _BYPASS_PERMS_RE = re.compile(r"^/bypass-permissions\s*$")
60
+ _REQUIRE_PERMS_RE = re.compile(r"^/require-permissions\s*$")
61
+
62
+ _HELP_TEXT = (
63
+ "Available commands:\n"
64
+ " /reset Start a fresh orchestrator session\n"
65
+ " /resume [<session_id>] Resume a past session (no arg → picker)\n"
66
+ " /rename [<id>] <title> Rename the active or a specific session\n"
67
+ " /cd <path> Re-root the workspace at <path>\n"
68
+ " /bypass-permissions Disable the permission modal (agents run freely)\n"
69
+ " /require-permissions Re-enable the permission modal (default)\n"
70
+ " /help Show this list"
71
+ )
72
+
73
+ _TITLE_PROMPT = (
74
+ "Summarize the following user message in 5-7 words for use as a session "
75
+ "title. Respond with ONLY the title — no quotes, no punctuation, no "
76
+ "preamble. Message:\n\n{message}"
77
+ )
78
+
79
+ # Appended to the default Claude Code system prompt for the orchestrator
80
+ # session. It tells the model to reach for the patchfeld MCP tools (the ones
81
+ # registered on the `patchfeld_orchestrator` MCP server, visible to the model
82
+ # as `mcp__patchfeld_orchestrator__<name>`) before falling back to generic
83
+ # Bash/Edit/Write/Read/Grep when a patchfeld tool already covers the job.
84
+ _ORCHESTRATOR_SYSTEM_APPEND = """\
85
+ ## Tool Preference (patchfeld)
86
+
87
+ You are running inside the patchfeld TUI. A `patchfeld_orchestrator` MCP server
88
+ exposes tools (visible as `mcp__patchfeld_orchestrator__<name>`) that mutate
89
+ the running app safely. **When a patchfeld tool can accomplish the task, call
90
+ it before falling back to Bash/Edit/Write/Read/Grep.** Editing the underlying
91
+ files or shelling out usually requires a restart and can desync the live UI.
92
+
93
+ - Layout / tabs: prefer `set_layout`, `get_layout`, `add_tab`, `close_tab`,
94
+ `switch_tab`, `rename_tab`, `reorder_tabs`, `save_layout`, `load_layout`,
95
+ `list_layouts`, `list_tabs`, `list_widgets` over editing workspace.json or
96
+ layout files by hand.
97
+ - Agents: prefer `spawn_agent`, `kill_agent`, `interrupt_agent`,
98
+ `send_to_agent`, `read_agent_transcript`, `respond_to_agent_request`,
99
+ `list_agents` over restarting the app or grepping log files.
100
+ - Workspace cwd: prefer `change_cwd` over editing config files or telling
101
+ the user to relaunch.
102
+ - Theme / config / keys: prefer `set_theme`, `save_theme`, `load_theme`,
103
+ `set_config`, `bind_key`, `unbind_key` over editing config.toml or theme
104
+ files directly.
105
+ - Custom widgets: prefer `save_widget` (persists to
106
+ ~/.config/patchfeld/widgets/ and registers live for use in the same
107
+ conversation) over `Write`-ing the file via the generic tool. For
108
+ one-off, throwaway widgets that should NOT be persisted, embed the
109
+ source in `LayoutSpec.custom_widgets` instead.
110
+
111
+ Generic tools (Bash, Edit, Write, Read, Grep) remain appropriate for
112
+ arbitrary source-code edits, running tests, git operations, and anything
113
+ no patchfeld tool covers.
114
+ """
115
+
116
+
117
+ class OrchestratorSession:
118
+ """The user's manager-Claude session. An AgentSession with extra MCP tools."""
119
+
120
+ AGENT_ID = "orchestrator"
121
+
122
+ def __init__(
123
+ self,
124
+ *,
125
+ cwd: Path,
126
+ bus: EventBus,
127
+ manager: AgentManager,
128
+ adapter: SDKAdapter | None = None,
129
+ model: str | None = None,
130
+ apply_layout=None,
131
+ layouts_store=None,
132
+ themes_store=None,
133
+ config_store=None,
134
+ actions=None,
135
+ rebind_keys=None,
136
+ widget_registry=None,
137
+ current_layout=None,
138
+ app=None,
139
+ permission_grants: PermissionGrants | None = None,
140
+ ) -> None:
141
+ self._cwd = cwd
142
+ self._bus = bus
143
+ self._manager = manager
144
+ self._model = model
145
+ self._adapter = adapter or RealSDKAdapter()
146
+ self._apply_layout = apply_layout
147
+ self._layouts_store = layouts_store
148
+ self._themes_store = themes_store
149
+ self._config_store = config_store
150
+ self._actions = actions
151
+ self._rebind_keys = rebind_keys
152
+ self._widget_registry = widget_registry
153
+ self._current_layout = current_layout
154
+ self._app = app
155
+ self._index = OrchestratorSessionsIndex(cwd=cwd)
156
+ self._sdk_session_id: str | None = None
157
+ self._active_transcript_path: Path | None = None
158
+ self._switching_lock = asyncio.Lock()
159
+ self._info = AgentInfo(
160
+ id=self.AGENT_ID,
161
+ name="orchestrator",
162
+ cwd=str(cwd),
163
+ started_at=time.time(),
164
+ )
165
+ self._current_session_first_message: str | None = None
166
+ self._current_session_num_turns: int = 0
167
+ self._inner: AgentSession | None = None # built in start()
168
+ self._unsub_user: callable = lambda: None
169
+ self._unsub_msg: callable = lambda: None
170
+ self._unsub_notify: callable = lambda: None
171
+ self._unsub_ask: callable = lambda: None
172
+ # Test-only seam: when set, used as the adapter factory for the next
173
+ # swap (during /reset or /resume). Production wiring uses RealSDKAdapter.
174
+ self._next_adapter_factory: "Callable[[], SDKAdapter] | None" = None
175
+ self._send_tasks: list[asyncio.Task] = []
176
+ # Production sets this to True after construction so new sessions
177
+ # get auto-titled. Defaults to False so tests don't spawn real Claude
178
+ # CLI subprocesses.
179
+ self._auto_title_enabled: bool = False
180
+ self._title_task: asyncio.Task | None = None
181
+ self._grants = permission_grants
182
+ self._can_use_tool_callback: CanUseTool | None = None
183
+ if permission_grants is not None:
184
+ self._perm_inbox: PermissionInbox | None = PermissionInbox(
185
+ on_pending_changed=self._on_perm_changed,
186
+ )
187
+ self._can_use_tool_callback = self._make_can_use_tool()
188
+ else:
189
+ self._perm_inbox = None
190
+
191
+ @property
192
+ def active_transcript_path(self) -> "Path | None":
193
+ return self._active_transcript_path
194
+
195
+ @property
196
+ def info(self) -> AgentInfo:
197
+ """The AgentInfo accumulating tokens, cost, and last-activity for the
198
+ orchestrator's own SDK session. Shared by reference with the inner
199
+ AgentSession, so reads always reflect the latest counters."""
200
+ return self._info
201
+
202
+ @property
203
+ def permission_grants(self) -> PermissionGrants | None:
204
+ return self._grants
205
+
206
+ def get_permission_inbox(self) -> PermissionInbox | None:
207
+ return self._perm_inbox
208
+
209
+ def _on_perm_changed(self, count: int) -> None:
210
+ # Until _inner exists, no state to mark. After start(), forward to
211
+ # _inner — same shape as the manager's _on_perm_changed.
212
+ inner = getattr(self, "_inner", None)
213
+ if inner is None:
214
+ return
215
+ if count > 0:
216
+ inner._mark_awaiting_permission()
217
+ else:
218
+ inner._mark_done_permission()
219
+
220
+ def _make_can_use_tool(self) -> CanUseTool:
221
+ bus = self._bus
222
+ grants = self._grants
223
+ agent_name = self.AGENT_ID # "orchestrator"
224
+ get_inbox = lambda: self._perm_inbox
225
+ TIMEOUT_S = 30 * 60
226
+
227
+ async def callback(
228
+ tool_name: str,
229
+ tool_input: dict,
230
+ ctx: ToolPermissionContext,
231
+ ):
232
+ assert grants is not None
233
+ decision = grants.lookup(agent_name=agent_name, tool_name=tool_name)
234
+ if decision == "allow":
235
+ return PermissionResultAllow()
236
+ if decision == "deny":
237
+ return PermissionResultDeny(message="denied by saved rule")
238
+ inbox = get_inbox()
239
+ if inbox is None:
240
+ return PermissionResultDeny(message="orchestrator gone", interrupt=True)
241
+ request_id = inbox.register(
242
+ tool_name=tool_name, tool_input=tool_input,
243
+ title=getattr(ctx, "title", None),
244
+ description=getattr(ctx, "description", None),
245
+ )
246
+ bus.publish(PermissionRequested(
247
+ agent_id="orchestrator", agent_name=agent_name,
248
+ request_id=request_id, tool_name=tool_name,
249
+ tool_input=tool_input,
250
+ title=getattr(ctx, "title", None),
251
+ description=getattr(ctx, "description", None),
252
+ ))
253
+ try:
254
+ result = await inbox.wait(request_id, timeout_s=TIMEOUT_S)
255
+ except asyncio.CancelledError:
256
+ task = asyncio.current_task()
257
+ if task is not None and task.cancelling() > 0:
258
+ raise
259
+ bus.publish(PermissionResolved(
260
+ agent_id="orchestrator", request_id=request_id,
261
+ behavior="cancelled",
262
+ ))
263
+ return PermissionResultDeny(message="cancelled", interrupt=True)
264
+ except asyncio.TimeoutError:
265
+ bus.publish(PermissionResolved(
266
+ agent_id="orchestrator", request_id=request_id,
267
+ behavior="deny",
268
+ ))
269
+ return PermissionResultDeny(message="timed out")
270
+ bus.publish(PermissionResolved(
271
+ agent_id="orchestrator", request_id=request_id,
272
+ behavior="allow" if isinstance(result, PermissionResultAllow) else "deny",
273
+ ))
274
+ return result
275
+
276
+ return callback
277
+
278
+ async def start(self) -> None:
279
+ # One-time migration of any pre-existing orchestrator.jsonl.
280
+ self._index.migrate_legacy_if_needed()
281
+
282
+ # Decide: resume vs new
283
+ prior = self._index.most_recent()
284
+ resume_id: str | None = None
285
+ if prior is not None and not prior.legacy:
286
+ resume_id = prior.session_id
287
+ session_id_for_options = None
288
+ transcript_path = orchestrator_session_transcript_path(
289
+ self._cwd, prior.session_id
290
+ )
291
+ self._sdk_session_id = prior.session_id
292
+ else:
293
+ # Canonical UUID form (8-4-4-4-12) is what Claude CLI expects on
294
+ # --session-id; bare hex (uuid.hex) is rejected at startup.
295
+ new_id = str(uuid.uuid4())
296
+ session_id_for_options = new_id
297
+ transcript_path = orchestrator_session_transcript_path(self._cwd, new_id)
298
+ self._sdk_session_id = new_id
299
+ self._active_transcript_path = transcript_path
300
+
301
+ await self._build_and_start_inner(
302
+ resume=resume_id, new_session_id=session_id_for_options,
303
+ transcript_path=transcript_path,
304
+ )
305
+ # Seed per-session counters from the resumed entry (or zero for a
306
+ # fresh start) so the StatusBar reflects the running total of the
307
+ # conversation we just attached to.
308
+ self._seed_counters_from(prior if resume_id is not None else None)
309
+
310
+ self._unsub_user = self._bus.subscribe(
311
+ UserMessageToOrchestrator, self._on_user_message
312
+ )
313
+ self._unsub_msg = self._bus.subscribe(
314
+ AgentMessageAppended, self._on_message_appended
315
+ )
316
+ self._unsub_notify = self._bus.subscribe(
317
+ AgentNotifiedOrchestrator, self._on_child_notified
318
+ )
319
+ self._unsub_ask = self._bus.subscribe(
320
+ AgentRequestedUserInput, self._on_child_asked
321
+ )
322
+
323
+ async def _build_and_start_inner(
324
+ self,
325
+ *,
326
+ resume: str | None,
327
+ new_session_id: str | None,
328
+ transcript_path: Path,
329
+ ) -> None:
330
+ mcp_server = build_orchestrator_mcp_server(
331
+ self._manager,
332
+ apply_layout=self._apply_layout,
333
+ layouts_store=self._layouts_store,
334
+ themes_store=self._themes_store,
335
+ config_store=self._config_store,
336
+ actions=self._actions,
337
+ rebind_keys=self._rebind_keys,
338
+ widget_registry=self._widget_registry,
339
+ current_layout=self._current_layout,
340
+ app=self._app,
341
+ )
342
+ options_kwargs: dict = {
343
+ "cwd": str(self._cwd),
344
+ "mcp_servers": {"patchfeld_orchestrator": mcp_server},
345
+ # Append a routing nudge to the default Claude Code system prompt
346
+ # so the model reaches for patchfeld_orchestrator MCP tools before
347
+ # falling back to Bash/Edit/Write/Read/Grep when a patchfeld tool
348
+ # already covers the task.
349
+ "system_prompt": SystemPromptPreset(
350
+ type="preset",
351
+ preset="claude_code",
352
+ append=_ORCHESTRATOR_SYSTEM_APPEND,
353
+ ),
354
+ }
355
+ # Permission posture mirrors AgentManager (see manager.py): when
356
+ # the user launches without --bypass-permissions, self._grants is
357
+ # set and we attach can_use_tool. The orchestrator routes through
358
+ # the same PermissionModal as child agents — the user reviews each
359
+ # tool call the orchestrator's Claude wants to make.
360
+ if self._grants is None:
361
+ options_kwargs["permission_mode"] = "bypassPermissions"
362
+ else:
363
+ options_kwargs["can_use_tool"] = self._can_use_tool_callback
364
+ if resume is not None:
365
+ options_kwargs["resume"] = resume
366
+ if new_session_id is not None:
367
+ options_kwargs["session_id"] = new_session_id
368
+ if self._model is not None:
369
+ options_kwargs["model"] = self._model
370
+
371
+ transcript = AgentTranscript(
372
+ cwd=self._cwd, agent_id=self.AGENT_ID, path=transcript_path,
373
+ )
374
+ self._inner = AgentSession(
375
+ info=self._info,
376
+ adapter=self._adapter,
377
+ transcript=transcript,
378
+ bus=self._bus,
379
+ on_session_id=self._on_session_id_observed,
380
+ )
381
+ await self._inner.start(options=ClaudeAgentOptions(**options_kwargs))
382
+
383
+ def _on_session_id_observed(self, session_id: str) -> None:
384
+ # Update in-memory pointer to whatever the SDK actually attached us to.
385
+ if self._sdk_session_id != session_id:
386
+ log.warning(
387
+ "orchestrator session_id mismatch: passed %s observed %s",
388
+ self._sdk_session_id, session_id,
389
+ )
390
+ self._sdk_session_id = session_id
391
+ # Note: _active_transcript_path is NOT re-pointed here — the
392
+ # AgentTranscript was already opened at the original path and
393
+ # all writes go there. We keep _active_transcript_path stable
394
+ # so callers (e.g. OrchestratorChat) can read from the right file.
395
+
396
+ existing = self._index.get(session_id)
397
+ now = time.time()
398
+ is_new_entry = existing is None
399
+ if existing is None:
400
+ entry = OrchestratorSessionEntry(
401
+ session_id=session_id,
402
+ transcript_path=str(self._active_transcript_path),
403
+ started_at=self._info.started_at,
404
+ last_activity=now,
405
+ first_user_message=self._current_session_first_message,
406
+ num_turns=self._current_session_num_turns,
407
+ tokens_in=self._info.tokens_in,
408
+ tokens_out=self._info.tokens_out,
409
+ cost=self._info.cost,
410
+ legacy=False,
411
+ )
412
+ else:
413
+ existing.last_activity = now
414
+ existing.tokens_in = self._info.tokens_in
415
+ existing.tokens_out = self._info.tokens_out
416
+ existing.cost = self._info.cost
417
+ entry = existing
418
+ self._index.upsert(entry)
419
+
420
+ # New session + first prompt available → fire async title summarizer.
421
+ if (
422
+ is_new_entry
423
+ and self._auto_title_enabled
424
+ and entry.title is None
425
+ and self._current_session_first_message
426
+ ):
427
+ self._title_task = asyncio.create_task(
428
+ self._generate_title_async(
429
+ session_id, self._current_session_first_message,
430
+ )
431
+ )
432
+
433
+ async def interrupt(self) -> None:
434
+ """Cancel the SDK's currently-running query, if any.
435
+
436
+ Safe to call when the orchestrator is idle — the underlying
437
+ adapter's interrupt is a no-op in that case.
438
+ """
439
+ if self._inner is not None:
440
+ await self._inner.interrupt()
441
+
442
+ async def wait_idle(self) -> None:
443
+ # queue_send eagerly clears _idle_event synchronously, so we no longer
444
+ # need sleep yields to drain the create_task scheduling gap.
445
+ # Wait for every outstanding send task to complete so that all queued
446
+ # messages have been fully processed (including the second+ messages
447
+ # that are serialised behind the AgentSession._send_lock).
448
+ if self._send_tasks:
449
+ pending = [t for t in self._send_tasks if not t.done()]
450
+ if pending:
451
+ await asyncio.gather(*pending, return_exceptions=True)
452
+ self._send_tasks.clear()
453
+ if self._inner is not None:
454
+ await self._inner.wait_idle()
455
+
456
+ async def stop(self) -> None:
457
+ if self._perm_inbox is not None:
458
+ self._perm_inbox.cancel_all()
459
+ self._unsub_user()
460
+ self._unsub_msg()
461
+ self._unsub_notify()
462
+ self._unsub_ask()
463
+ if self._inner is not None:
464
+ await self._inner.stop()
465
+
466
+ # --- internals --------------------------------------------------------
467
+
468
+ def _on_user_message(self, event: UserMessageToOrchestrator) -> None:
469
+ if self._inner is None:
470
+ return
471
+ text = event.text
472
+ # Slash-command interception. Only triggers on bare-prefix matches —
473
+ # synthetic messages from child agents are wrapped in "[from agent ...]"
474
+ # and so cannot match.
475
+ if _RESET_RE.match(text):
476
+ self._send_tasks = [t for t in self._send_tasks if not t.done()]
477
+ self._send_tasks.append(asyncio.create_task(self.reset()))
478
+ return
479
+ if _RESUME_BARE_RE.match(text):
480
+ self._bus.publish(OpenResumePicker())
481
+ return
482
+ m = _RESUME_ID_RE.match(text)
483
+ if m:
484
+ self._send_tasks = [t for t in self._send_tasks if not t.done()]
485
+ self._send_tasks.append(asyncio.create_task(self.resume(m.group(1))))
486
+ return
487
+ m = _RENAME_RE.match(text)
488
+ if m:
489
+ self._handle_rename_command(m.group(1) or "")
490
+ return
491
+ m = _CD_RE.match(text)
492
+ if m and self._app is not None:
493
+ path = m.group(1).strip()
494
+ self._send_tasks = [t for t in self._send_tasks if not t.done()]
495
+ self._send_tasks.append(
496
+ asyncio.create_task(self._handle_cd_command(path))
497
+ )
498
+ return
499
+ if _BYPASS_PERMS_RE.match(text) and self._app is not None:
500
+ self._send_tasks = [t for t in self._send_tasks if not t.done()]
501
+ self._send_tasks.append(
502
+ asyncio.create_task(self._handle_set_permissions(bypass=True))
503
+ )
504
+ return
505
+ if _REQUIRE_PERMS_RE.match(text) and self._app is not None:
506
+ self._send_tasks = [t for t in self._send_tasks if not t.done()]
507
+ self._send_tasks.append(
508
+ asyncio.create_task(self._handle_set_permissions(bypass=False))
509
+ )
510
+ return
511
+ if _HELP_RE.match(text):
512
+ self._publish_notice(_HELP_TEXT)
513
+ return
514
+ # Fall through: ordinary prompt.
515
+ if self._current_session_first_message is None:
516
+ self._current_session_first_message = text
517
+ self._send_tasks = [t for t in self._send_tasks if not t.done()]
518
+ task = self._inner.queue_send(text)
519
+ self._send_tasks.append(task)
520
+
521
+ def _handle_rename_command(self, args: str) -> None:
522
+ """Handle /rename invocations.
523
+
524
+ Forms:
525
+ /rename → notice on missing title
526
+ /rename <title> → renames the active session
527
+ /rename <session_id> <title> → renames a specific session
528
+ """
529
+ args = args.strip()
530
+ if not args:
531
+ self._publish_notice(
532
+ "Usage: /rename <new title> or /rename <session_id> <title>"
533
+ )
534
+ return
535
+ # If the first token matches a known session_id, treat it as
536
+ # /rename <id> <title>; otherwise the whole thing is the active title.
537
+ first, _, rest = args.partition(" ")
538
+ rest = rest.strip()
539
+ candidate_entry = self._index.get(first)
540
+ if candidate_entry is not None and rest:
541
+ target_id = first
542
+ new_title: str | None = rest
543
+ else:
544
+ target_id = self._sdk_session_id or ""
545
+ new_title = args
546
+ if not target_id:
547
+ self._publish_notice("No active session to rename.")
548
+ return
549
+ if not new_title:
550
+ new_title = None # explicit clear
551
+ ok = self._index.set_title(target_id, new_title)
552
+ if not ok:
553
+ self._publish_notice(f"No such session: {target_id}")
554
+ return
555
+ label = new_title if new_title else "(cleared)"
556
+ self._publish_notice(f"Renamed session to: {label}")
557
+
558
+ async def _handle_cd_command(self, path: str) -> None:
559
+ if self._app is None:
560
+ return
561
+ result = await self._app.change_cwd(path)
562
+ if "error" in result:
563
+ err = result["error"]
564
+ if err == "agents_running":
565
+ names = ", ".join(a["name"] for a in result.get("agents", []))
566
+ self._publish_notice(
567
+ f"Refusing /cd: agents still running ({names})."
568
+ )
569
+ elif err == "invalid_path":
570
+ self._publish_notice(
571
+ f"Invalid path: {result.get('path') or result.get('detail')}"
572
+ )
573
+ else:
574
+ self._publish_notice(f"/cd failed: {err}")
575
+ elif "unchanged" in result:
576
+ self._publish_notice("cwd unchanged.")
577
+ else:
578
+ self._publish_notice(f"cwd → {result['changed']}")
579
+
580
+ async def _handle_set_permissions(self, *, bypass: bool) -> None:
581
+ if self._app is None:
582
+ return
583
+ result = await self._app.set_bypass_permissions(bypass)
584
+ if "error" in result:
585
+ err = result["error"]
586
+ if err == "agents_running":
587
+ names = ", ".join(a["name"] for a in result.get("agents", []))
588
+ self._publish_notice(
589
+ f"Refusing /{'bypass' if bypass else 'require'}-permissions: "
590
+ f"agents still running ({names})."
591
+ )
592
+ else:
593
+ self._publish_notice(
594
+ f"/{'bypass' if bypass else 'require'}-permissions failed: {err}"
595
+ )
596
+ elif "unchanged" in result:
597
+ mode = "bypass" if bypass else "require"
598
+ self._publish_notice(f"Permission mode already: {mode}")
599
+ else:
600
+ mode = result["changed"]
601
+ self._publish_notice(f"Permission mode is now: {mode}")
602
+
603
+ async def _generate_title_async(self, session_id: str, first_user_message: str) -> None:
604
+ """Issue a one-shot SDK query to summarize the first message into a
605
+ 5-7 word title. Silently no-ops on any failure."""
606
+ try:
607
+ text = await self._summarize_for_title(first_user_message)
608
+ except Exception:
609
+ log.exception("title summarization failed for %s", session_id)
610
+ return
611
+ if not text:
612
+ return
613
+ # Strip stray quotes/punctuation a model might add despite instructions.
614
+ title = text.strip().strip('"\'').rstrip(".").strip()
615
+ if not title:
616
+ return
617
+ # Cap to a reasonable display length even if the model went over.
618
+ if len(title) > 80:
619
+ title = title[:79] + "…"
620
+ self._index.set_title(session_id, title)
621
+
622
+ async def _summarize_for_title(self, first_user_message: str) -> str:
623
+ """One-shot SDK query that returns the model's title text.
624
+
625
+ Isolated as its own method so tests can monkeypatch it without
626
+ spawning a real subprocess.
627
+ """
628
+ prompt = _TITLE_PROMPT.format(message=first_user_message)
629
+ options = ClaudeAgentOptions(
630
+ cwd=str(self._cwd),
631
+ permission_mode="bypassPermissions",
632
+ model="claude-haiku-4-5",
633
+ )
634
+ chunks: list[str] = []
635
+ async for msg in sdk_query(prompt=prompt, options=options):
636
+ if isinstance(msg, AssistantMessage):
637
+ for block in msg.content:
638
+ if isinstance(block, TextBlock):
639
+ chunks.append(block.text)
640
+ return " ".join(chunks).strip()
641
+
642
+ async def reset(self) -> None:
643
+ async with self._switching_lock:
644
+ await self._swap_inner(resume=None)
645
+ self._seed_counters_from(None)
646
+
647
+ async def resume(self, session_id: str) -> None:
648
+ async with self._switching_lock:
649
+ entry = self._index.get(session_id)
650
+ if entry is None:
651
+ self._publish_notice(f"No such session: {session_id}")
652
+ return
653
+ if entry.legacy:
654
+ self._publish_notice(
655
+ "This session predates SDK resume support; starting a fresh session."
656
+ )
657
+ await self._swap_inner(resume=None)
658
+ self._seed_counters_from(None)
659
+ return
660
+ try:
661
+ await self._swap_inner(resume=session_id)
662
+ self._seed_counters_from(entry)
663
+ except Exception:
664
+ log.exception("SDK rejected resume=%s; falling back to fresh", session_id)
665
+ self._publish_notice(
666
+ f"Could not resume {session_id}; starting a fresh session."
667
+ )
668
+ self._inner = None
669
+ await self._swap_inner(resume=None)
670
+ self._seed_counters_from(None)
671
+
672
+ def _seed_counters_from(self, entry: "OrchestratorSessionEntry | None") -> None:
673
+ """Reset (entry=None) or seed (entry=resumed entry) the per-session
674
+ token / cost counters on self._info, then publish AgentTokensTouched
675
+ so the StatusBar aggregator picks up the change."""
676
+ if entry is None:
677
+ self._info.tokens_in = 0
678
+ self._info.tokens_out = 0
679
+ self._info.cost = 0.0
680
+ else:
681
+ self._info.tokens_in = entry.tokens_in
682
+ self._info.tokens_out = entry.tokens_out
683
+ self._info.cost = entry.cost
684
+ self._bus.publish(AgentTokensTouched(agent_id=self._info.id))
685
+
686
+ def _publish_notice(self, text: str) -> None:
687
+ # Toast for the running app (production UI surface).
688
+ if self._app is not None:
689
+ try:
690
+ self._app.notify(text, title="orchestrator")
691
+ except Exception:
692
+ pass
693
+ # OrchestratorReply event for tests + bus subscribers.
694
+ self._bus.publish(OrchestratorReply(text))
695
+
696
+ async def _swap_inner(self, *, resume: str | None) -> None:
697
+ if self._perm_inbox is not None:
698
+ self._perm_inbox.cancel_all()
699
+ # Stop current, start a new inner with either resume=<id> or a fresh id.
700
+ if self._inner is not None:
701
+ try:
702
+ await self._inner.interrupt()
703
+ except Exception:
704
+ pass
705
+ await self._inner.stop()
706
+
707
+ if resume is not None:
708
+ new_session_id = None
709
+ transcript_path = orchestrator_session_transcript_path(self._cwd, resume)
710
+ self._sdk_session_id = resume
711
+ else:
712
+ new_id = str(uuid.uuid4())
713
+ new_session_id = new_id
714
+ transcript_path = orchestrator_session_transcript_path(self._cwd, new_id)
715
+ self._sdk_session_id = new_id
716
+ self._current_session_first_message = None
717
+ self._current_session_num_turns = 0
718
+ self._active_transcript_path = transcript_path
719
+
720
+ # Pull a fresh adapter. In production this comes from the
721
+ # RealSDKAdapter factory; tests can inject _next_adapter_factory.
722
+ if self._next_adapter_factory is not None:
723
+ self._adapter = self._next_adapter_factory()
724
+ self._next_adapter_factory = None
725
+ else:
726
+ self._adapter = RealSDKAdapter()
727
+
728
+ await self._build_and_start_inner(
729
+ resume=resume, new_session_id=new_session_id,
730
+ transcript_path=transcript_path,
731
+ )
732
+
733
+ self._bus.publish(OrchestratorSessionSwitched(
734
+ session_id=self._sdk_session_id,
735
+ transcript_path=str(self._active_transcript_path),
736
+ ))
737
+
738
+ def _on_message_appended(self, event: AgentMessageAppended) -> None:
739
+ if event.agent_id != self.AGENT_ID:
740
+ return
741
+ # RichTranscript subscribes to AgentMessageAppended directly for tool
742
+ # use/result/thinking — only re-publish assistant text, which is the
743
+ # public "the orchestrator said something" signal other code asserts on.
744
+ if event.role == "assistant":
745
+ self._bus.publish(OrchestratorReply(event.text))
746
+ self._current_session_num_turns += 1
747
+ self._refresh_session_summary()
748
+
749
+ def _refresh_session_summary(self) -> None:
750
+ """Update the index entry for the active session with current
751
+ first_user_message + num_turns + activity. No-op if the session
752
+ hasn't been confirmed (_sdk_session_id not yet observed) or
753
+ if the entry hasn't been created yet (handled by
754
+ _on_session_id_observed)."""
755
+ if self._sdk_session_id is None:
756
+ return
757
+ existing = self._index.get(self._sdk_session_id)
758
+ if existing is None:
759
+ return
760
+ existing.last_activity = time.time()
761
+ # Only set first_user_message if not already set — the very first
762
+ # prompt of the session is the canonical answer; later prompts
763
+ # don't overwrite.
764
+ if existing.first_user_message is None and self._current_session_first_message:
765
+ existing.first_user_message = self._current_session_first_message
766
+ existing.num_turns = max(existing.num_turns, self._current_session_num_turns)
767
+ existing.tokens_in = self._info.tokens_in
768
+ existing.tokens_out = self._info.tokens_out
769
+ existing.cost = self._info.cost
770
+ self._index.upsert(existing)
771
+
772
+ def _on_child_notified(self, event: AgentNotifiedOrchestrator) -> None:
773
+ synthetic = (
774
+ f"[from agent {event.agent_id}] {event.message}"
775
+ )
776
+ self._bus.publish(UserMessageToOrchestrator(synthetic))
777
+
778
+ def _on_child_asked(self, event: AgentRequestedUserInput) -> None:
779
+ synthetic = (
780
+ f"[agent {event.agent_id} is blocked waiting for your reply, "
781
+ f"request_id={event.request_id}] question: {event.question}\n"
782
+ f"Use respond_to_agent_request(agent_id={event.agent_id!r}, "
783
+ f"request_id={event.request_id!r}, response=...) to unblock."
784
+ )
785
+ self._bus.publish(UserMessageToOrchestrator(synthetic))