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