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.
- patchbai/__init__.py +1 -0
- patchbai/__main__.py +10 -0
- patchbai/actions.py +34 -0
- patchbai/activity/__init__.py +0 -0
- patchbai/activity/log.py +237 -0
- patchbai/agents/__init__.py +0 -0
- patchbai/agents/child_tools.py +66 -0
- patchbai/agents/fake_sdk_adapter.py +45 -0
- patchbai/agents/manager.py +272 -0
- patchbai/agents/request_inbox.py +65 -0
- patchbai/agents/sdk_adapter.py +49 -0
- patchbai/agents/session.py +224 -0
- patchbai/agents/sort.py +66 -0
- patchbai/agents/state.py +80 -0
- patchbai/app.py +1288 -0
- patchbai/config.py +128 -0
- patchbai/events.py +236 -0
- patchbai/layout/__init__.py +0 -0
- patchbai/layout/custom_widgets.py +82 -0
- patchbai/layout/defaults.py +33 -0
- patchbai/layout/engine.py +241 -0
- patchbai/layout/local_widgets.py +188 -0
- patchbai/layout/registry.py +69 -0
- patchbai/layout/spec.py +104 -0
- patchbai/layout/splitter.py +170 -0
- patchbai/layout/titles.py +70 -0
- patchbai/orchestrator/__init__.py +0 -0
- patchbai/orchestrator/formatting.py +15 -0
- patchbai/orchestrator/session.py +644 -0
- patchbai/orchestrator/tabs_tools.py +149 -0
- patchbai/orchestrator/tools.py +976 -0
- patchbai/persistence/__init__.py +0 -0
- patchbai/persistence/agents_index.py +68 -0
- patchbai/persistence/atomic.py +47 -0
- patchbai/persistence/layout_store.py +25 -0
- patchbai/persistence/layouts_store.py +61 -0
- patchbai/persistence/orchestrator_sessions.py +127 -0
- patchbai/persistence/paths.py +48 -0
- patchbai/persistence/themes_store.py +44 -0
- patchbai/persistence/transcript_store.py +64 -0
- patchbai/persistence/workspace_store.py +25 -0
- patchbai/theme/__init__.py +0 -0
- patchbai/theme/engine.py +75 -0
- patchbai/theme/spec.py +31 -0
- patchbai/widgets/__init__.py +0 -0
- patchbai/widgets/_file_lang.py +36 -0
- patchbai/widgets/_terminal_keys.py +89 -0
- patchbai/widgets/_terminal_render.py +147 -0
- patchbai/widgets/activity_feed.py +365 -0
- patchbai/widgets/agent_table.py +235 -0
- patchbai/widgets/agent_transcript.py +58 -0
- patchbai/widgets/change_cwd_screen.py +39 -0
- patchbai/widgets/chrome.py +210 -0
- patchbai/widgets/diff_viewer.py +52 -0
- patchbai/widgets/file_editor.py +258 -0
- patchbai/widgets/file_tree.py +33 -0
- patchbai/widgets/file_viewer.py +77 -0
- patchbai/widgets/history_screen.py +58 -0
- patchbai/widgets/layout_switcher.py +126 -0
- patchbai/widgets/log_tail.py +113 -0
- patchbai/widgets/markdown.py +65 -0
- patchbai/widgets/new_tab_screen.py +31 -0
- patchbai/widgets/notebook.py +45 -0
- patchbai/widgets/orchestrator_chat.py +73 -0
- patchbai/widgets/resume_screen.py +179 -0
- patchbai/widgets/rich_transcript.py +606 -0
- patchbai/widgets/terminal.py +251 -0
- patchbai/widgets/theme_switcher.py +63 -0
- patchbai/widgets/transcript_screen.py +39 -0
- patchbai/workspace/__init__.py +3 -0
- patchbai/workspace/spec.py +72 -0
- patchbai-0.1.0.dist-info/METADATA +573 -0
- patchbai-0.1.0.dist-info/RECORD +76 -0
- patchbai-0.1.0.dist-info/WHEEL +4 -0
- patchbai-0.1.0.dist-info/entry_points.txt +3 -0
- 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))
|