symphony-code 0.0.1__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 (169) hide show
  1. coding_agent/__init__.py +20 -0
  2. coding_agent/_lazy.py +17 -0
  3. coding_agent/addons/__init__.py +35 -0
  4. coding_agent/addons/approvals.py +224 -0
  5. coding_agent/addons/compaction/__init__.py +36 -0
  6. coding_agent/addons/compaction/addon.py +88 -0
  7. coding_agent/addons/compaction/compactor.py +138 -0
  8. coding_agent/addons/compaction/prompts.py +20 -0
  9. coding_agent/addons/compaction/transcript.py +118 -0
  10. coding_agent/addons/evaluation/__init__.py +68 -0
  11. coding_agent/addons/evaluation/addon.py +199 -0
  12. coding_agent/addons/evaluation/evaluators.py +245 -0
  13. coding_agent/addons/evaluation/handlers.py +66 -0
  14. coding_agent/addons/evaluation/policy.py +277 -0
  15. coding_agent/addons/evaluation/protocol.py +192 -0
  16. coding_agent/addons/evaluation/state.py +284 -0
  17. coding_agent/addons/langfuse/__init__.py +24 -0
  18. coding_agent/addons/langfuse/addon.py +465 -0
  19. coding_agent/addons/langfuse/install.py +79 -0
  20. coding_agent/addons/langfuse/serialize.py +158 -0
  21. coding_agent/addons/learning/__init__.py +33 -0
  22. coding_agent/addons/learning/addon.py +181 -0
  23. coding_agent/addons/learning/loop.py +247 -0
  24. coding_agent/addons/learning/prompts.py +26 -0
  25. coding_agent/addons/learning/sanitize.py +64 -0
  26. coding_agent/addons/learning/store.py +522 -0
  27. coding_agent/addons/manager/__init__.py +18 -0
  28. coding_agent/addons/manager/bridge.py +74 -0
  29. coding_agent/addons/manager/discovery.py +34 -0
  30. coding_agent/addons/manager/report.py +33 -0
  31. coding_agent/addons/manager/runtime.py +76 -0
  32. coding_agent/addons/manager/tool_changes.py +16 -0
  33. coding_agent/addons/persistence/__init__.py +41 -0
  34. coding_agent/addons/persistence/active.py +132 -0
  35. coding_agent/addons/persistence/artifacts.py +110 -0
  36. coding_agent/addons/persistence/collection.py +166 -0
  37. coding_agent/addons/persistence/compatibility.py +30 -0
  38. coding_agent/addons/persistence/images.py +133 -0
  39. coding_agent/addons/persistence/jsonl.py +1195 -0
  40. coding_agent/addons/persistence/presentation.py +104 -0
  41. coding_agent/addons/plugins/__init__.py +19 -0
  42. coding_agent/addons/plugins/install.py +37 -0
  43. coding_agent/addons/plugins/manager.py +140 -0
  44. coding_agent/addons/plugins/models.py +50 -0
  45. coding_agent/addons/skills/__init__.py +28 -0
  46. coding_agent/addons/skills/addon.py +26 -0
  47. coding_agent/addons/skills/args.py +78 -0
  48. coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
  49. coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
  50. coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
  51. coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
  52. coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
  53. coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
  54. coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
  55. coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
  56. coding_agent/addons/skills/frontmatter.py +57 -0
  57. coding_agent/addons/skills/models.py +57 -0
  58. coding_agent/addons/skills/registry.py +109 -0
  59. coding_agent/addons/skills/tool.py +54 -0
  60. coding_agent/addons/voice/__init__.py +5 -0
  61. coding_agent/addons/voice/addon.py +106 -0
  62. coding_agent/addons/voice/credentials.py +42 -0
  63. coding_agent/addons/voice/playback.py +42 -0
  64. coding_agent/addons/voice/speak.py +287 -0
  65. coding_agent/addons/voice/stream.py +651 -0
  66. coding_agent/addons/voice/summary.py +126 -0
  67. coding_agent/agent.py +523 -0
  68. coding_agent/bench/__init__.py +1 -0
  69. coding_agent/bench/cli.py +200 -0
  70. coding_agent/bench/patch.py +78 -0
  71. coding_agent/commands/__init__.py +5 -0
  72. coding_agent/commands/catalog.py +179 -0
  73. coding_agent/commands/jev_argument.py +40 -0
  74. coding_agent/config/__init__.py +51 -0
  75. coding_agent/config/config.py +376 -0
  76. coding_agent/config/credentials.py +153 -0
  77. coding_agent/config/personalities.json +34 -0
  78. coding_agent/config/personalities.py +170 -0
  79. coding_agent/config/prompts.py +67 -0
  80. coding_agent/protocols/__init__.py +5 -0
  81. coding_agent/protocols/acp/README.md +36 -0
  82. coding_agent/protocols/acp/__init__.py +27 -0
  83. coding_agent/protocols/acp/mapping.py +164 -0
  84. coding_agent/protocols/acp/server.py +261 -0
  85. coding_agent/protocols/acp/transport.py +72 -0
  86. coding_agent/protocols/stdio/README.md +36 -0
  87. coding_agent/protocols/stdio/__init__.py +37 -0
  88. coding_agent/protocols/stdio/catalog.py +49 -0
  89. coding_agent/protocols/stdio/commands.py +218 -0
  90. coding_agent/protocols/stdio/server.py +181 -0
  91. coding_agent/protocols/stdio/transport.py +71 -0
  92. coding_agent/py.typed +0 -0
  93. coding_agent/resources/__init__.py +5 -0
  94. coding_agent/resources/usage.py +264 -0
  95. coding_agent/run/__init__.py +1 -0
  96. coding_agent/run/cli.py +99 -0
  97. coding_agent/run/detach.py +37 -0
  98. coding_agent/run/interrupted.py +67 -0
  99. coding_agent/run/log_sink.py +46 -0
  100. coding_agent/tools/__init__.py +73 -0
  101. coding_agent/tools/ask_user.py +61 -0
  102. coding_agent/tools/base.py +136 -0
  103. coding_agent/tools/bash.py +288 -0
  104. coding_agent/tools/bash_jobs.py +116 -0
  105. coding_agent/tools/generate_image.py +123 -0
  106. coding_agent/tools/memory.py +100 -0
  107. coding_agent/tools/patch.py +218 -0
  108. coding_agent/tools/read_file.py +167 -0
  109. coding_agent/tools/search.py +433 -0
  110. coding_agent/tools/web_search.py +151 -0
  111. coding_agent/tools/write_file.py +40 -0
  112. coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
  113. coding_agent/tui/__init__.py +22 -0
  114. coding_agent/tui/__main__.py +122 -0
  115. coding_agent/tui/app.py +456 -0
  116. coding_agent/tui/chrome/__init__.py +33 -0
  117. coding_agent/tui/chrome/footer.py +274 -0
  118. coding_agent/tui/chrome/topbar.py +259 -0
  119. coding_agent/tui/commands/__init__.py +45 -0
  120. coding_agent/tui/commands/catalog.py +35 -0
  121. coding_agent/tui/commands/manager.py +557 -0
  122. coding_agent/tui/composer/__init__.py +18 -0
  123. coding_agent/tui/composer/input.py +219 -0
  124. coding_agent/tui/composer/slash_menu.py +341 -0
  125. coding_agent/tui/composer/surface.py +330 -0
  126. coding_agent/tui/composer/voice.py +267 -0
  127. coding_agent/tui/composer/voice_mode.py +491 -0
  128. coding_agent/tui/runtime/__init__.py +23 -0
  129. coding_agent/tui/runtime/events.py +728 -0
  130. coding_agent/tui/runtime/sink.py +115 -0
  131. coding_agent/tui/runtime/state.py +145 -0
  132. coding_agent/tui/runtime/subagent.py +531 -0
  133. coding_agent/tui/runtime/turn.py +206 -0
  134. coding_agent/tui/screens/__init__.py +54 -0
  135. coding_agent/tui/screens/ask.py +69 -0
  136. coding_agent/tui/screens/context.py +195 -0
  137. coding_agent/tui/screens/dashboard.py +91 -0
  138. coding_agent/tui/screens/diff.py +302 -0
  139. coding_agent/tui/screens/extensions.py +68 -0
  140. coding_agent/tui/screens/file_selector.py +167 -0
  141. coding_agent/tui/screens/history.py +458 -0
  142. coding_agent/tui/screens/langfuse.py +142 -0
  143. coding_agent/tui/screens/learning.py +113 -0
  144. coding_agent/tui/screens/modal.py +131 -0
  145. coding_agent/tui/screens/onboard.py +455 -0
  146. coding_agent/tui/screens/resume.py +163 -0
  147. coding_agent/tui/theme/__init__.py +49 -0
  148. coding_agent/tui/theme/load.py +325 -0
  149. coding_agent/tui/theme/style.py +95 -0
  150. coding_agent/tui/theme/theme.toml +1687 -0
  151. coding_agent/tui/tools/__init__.py +53 -0
  152. coding_agent/tui/tools/activity.py +184 -0
  153. coding_agent/tui/tools/calls.py +560 -0
  154. coding_agent/tui/tools/code_view.py +52 -0
  155. coding_agent/tui/tools/images.py +250 -0
  156. coding_agent/tui/tools/labels.py +135 -0
  157. coding_agent/tui/tools/modal.py +137 -0
  158. coding_agent/tui/tools/partial_args.py +19 -0
  159. coding_agent/tui/tools/snapshots.py +382 -0
  160. coding_agent/tui/transcript/__init__.py +36 -0
  161. coding_agent/tui/transcript/messages.py +408 -0
  162. coding_agent/tui/transcript/process.py +504 -0
  163. coding_agent/tui/transcript/surface.py +345 -0
  164. coding_agent/tui/transcript/thinking_markdown.py +314 -0
  165. symphony_code-0.0.1.dist-info/METADATA +66 -0
  166. symphony_code-0.0.1.dist-info/RECORD +169 -0
  167. symphony_code-0.0.1.dist-info/WHEEL +4 -0
  168. symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
  169. symphony_code-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,531 @@
1
+ """Nested transcript UI for spawned child agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any, Mapping, Optional
9
+
10
+ from rich.text import Text
11
+ from textual.binding import Binding
12
+ from textual.widgets import Static, OptionList
13
+ from textual.widgets.option_list import Option
14
+
15
+ from coding_agent.addons.persistence.collection import is_collected
16
+ from coding_agent.tui.chrome import TopBar, render_footer, display_workspace_path
17
+ from coding_agent.tui.runtime.events import EventPresenter
18
+ from coding_agent.tui.runtime.state import UiRunState
19
+ from coding_agent.tui.screens.modal import ModalBase
20
+ from coding_agent.tui.theme import APP_CSS
21
+ from coding_agent.tui.transcript import (
22
+ UserMessage,
23
+ TranscriptScroll,
24
+ TranscriptSurface,
25
+ )
26
+ from coding_agent.tui.tools import BashToolHeader, ToolCallWidget
27
+ from coding_agent.tui.transcript import clip_text, compact_json, preview_text
28
+
29
+
30
+ @dataclass
31
+ class SubagentRecord:
32
+ """Live child-agent transcript, keyed by ``agent_id``."""
33
+
34
+ agent_id: str
35
+ parent_id: str
36
+ label: str
37
+ prompt: str
38
+ status: str = "running"
39
+ model_id: str = ""
40
+ output_text: str = ""
41
+ events: list[tuple[str, dict[str, Any]]] = field(default_factory=list)
42
+ tools: list[dict[str, Any]] = field(default_factory=list)
43
+ session_id: str = ""
44
+ tool_call_id: str = ""
45
+ run_id: str = ""
46
+ config: dict[str, Any] = field(default_factory=dict)
47
+ _seen_events: set[tuple[str, int]] = field(default_factory=set, repr=False)
48
+ _widgets: list[Any] = field(default_factory=list, repr=False)
49
+
50
+ def _tool(self, payload: dict[str, Any]) -> dict[str, Any]:
51
+ call_id = str(payload.get("tool_call_id") or f"tool-{len(self.tools)}")
52
+ existing = next(
53
+ (tool for tool in self.tools if tool.get("id") == call_id),
54
+ None,
55
+ )
56
+ if existing is not None:
57
+ return existing
58
+ tool = {
59
+ "id": call_id,
60
+ "name": str(payload.get("tool_name") or "tool"),
61
+ "status": "preparing",
62
+ "arguments": {},
63
+ "raw_arguments": "",
64
+ "summary": "",
65
+ "result": "",
66
+ }
67
+ self.tools.append(tool)
68
+ return tool
69
+
70
+ def ingest(self, event_type: str, payload: dict[str, Any]) -> None:
71
+ if event_type == "collected" or is_collected(payload):
72
+ return
73
+ if payload.get("parent_id") and (
74
+ payload.get("agent_id") != self.agent_id
75
+ or payload.get("parent_id") != self.parent_id
76
+ ):
77
+ return
78
+ if payload.get("run_id") and isinstance(payload.get("seq"), int):
79
+ key = (str(payload["run_id"]), payload["seq"])
80
+ if key in self._seen_events:
81
+ return
82
+ self._seen_events.add(key)
83
+ self.events.append((event_type, dict(payload)))
84
+ if event_type == "run_started":
85
+ self.run_id = str(payload.get("run_id") or self.run_id)
86
+ self.session_id = str(payload.get("session_id") or self.session_id)
87
+ self.status = "running"
88
+ self.output_text = ""
89
+ self.model_id = str(payload.get("model_id") or self.model_id)
90
+ elif event_type == "tool_call_started":
91
+ tool = self._tool(payload)
92
+ tool["name"] = str(payload.get("tool_name") or tool["name"])
93
+ elif event_type in {"tool_call_delta", "tool_call_arguments"}:
94
+ tool = self._tool(payload)
95
+ if event_type == "tool_call_arguments":
96
+ raw = payload.get("arguments")
97
+ if not isinstance(raw, str):
98
+ return
99
+ else:
100
+ raw = str(tool.get("raw_arguments") or "") + str(payload.get("delta") or "")
101
+ tool["raw_arguments"] = raw
102
+ tool["summary"] = raw
103
+ try:
104
+ arguments = json.loads(raw)
105
+ except json.JSONDecodeError:
106
+ arguments = None
107
+ if isinstance(arguments, dict):
108
+ tool["arguments"] = arguments
109
+ tool["summary"] = compact_json(arguments)
110
+ elif event_type == "tool_execution_started":
111
+ tool = self._tool(payload)
112
+ arguments = payload.get("arguments") or {}
113
+ tool.update(
114
+ name=str(payload.get("tool_name") or tool["name"]),
115
+ status="running",
116
+ arguments=arguments,
117
+ summary=compact_json(arguments) if arguments else tool["summary"],
118
+ )
119
+ elif event_type == "tool_execution_completed":
120
+ tool = self._tool(payload)
121
+ result = payload.get("result") or ""
122
+ tool.update(
123
+ name=str(payload.get("tool_name") or tool["name"]),
124
+ status=(
125
+ "failed" if str(payload.get("status") or "") == "error" else "done"
126
+ ),
127
+ result=preview_text(result, limit=400),
128
+ )
129
+ elif event_type == "text_delta":
130
+ self.output_text += str(payload.get("delta") or "")
131
+ elif event_type in {"run_completed", "agent_completed"}:
132
+ self.status = "completed"
133
+ if payload.get("output_text"):
134
+ self.output_text = str(payload["output_text"])
135
+ elif event_type in {"run_failed", "run_cancelled", "run_limit_exceeded", "agent_failed"}:
136
+ self.status = "cancelled" if event_type == "run_cancelled" or payload.get("error_type") == "HarnessCancelled" else "failed"
137
+ if payload.get("message"):
138
+ self.output_text = str(payload["message"])
139
+
140
+
141
+ class SubagentScreen(TranscriptSurface, ModalBase[None]):
142
+ """Full-screen child transcript using the parent session's visual language."""
143
+
144
+ # Use the same chrome and transcript surface as the parent app. This is
145
+ # intentionally not a second, legacy subagent layout: the child prompt is
146
+ # the first user message and the child run uses the normal process cards.
147
+ CSS = APP_CSS + """
148
+ SubagentScreen { border: round #484F58; }
149
+ #child-heading { height: 2; padding: 0 $space-gutter; color: #a2adb8; }
150
+ #child-back { height: 1; padding: 0 $space-gutter; color: #737373; }
151
+ """
152
+ BINDINGS = [
153
+ Binding("escape,q", "close_modal", "Back to parent", show=False, priority=True),
154
+ Binding("ctrl+x", "stop_child", "Stop child", show=False, priority=True),
155
+ ]
156
+
157
+ def __init__(self, record: SubagentRecord | None = None, workspace: Any = None,
158
+ *, child_id: str = "", parent_id: str = "") -> None:
159
+ super().__init__()
160
+ self.record = record
161
+ self.child_id = child_id or (record.agent_id if record else "")
162
+ self.parent_id = parent_id or (record.parent_id if record else "")
163
+ self.workspace = Path(workspace or ".").resolve()
164
+ self._thinking = self._process = self._assistant = self._reasoning = None
165
+ self._tools = {}
166
+ self._tool_groups = {}
167
+ self._open_tool_group = None
168
+ self._subagents = {}
169
+ self._busy = True
170
+ self._event_cursor = 0
171
+ self._ready = False
172
+ self._stream_flush_timer = None
173
+ self._ui_state = UiRunState()
174
+ self._presenter = EventPresenter(
175
+ state=self._ui_state, view=self, set_status=self._set_status,
176
+ workspace=str(self.workspace), schedule_flush=self._schedule_stream_flush,
177
+ )
178
+
179
+ def compose(self): # type: ignore[no-untyped-def]
180
+ yield TopBar(id="topbar")
181
+ yield Static(id="child-heading", markup=False)
182
+ yield TranscriptScroll(id="transcript")
183
+ yield Static("Viewing child transcript · Esc back · Ctrl+X stop child", id="child-back")
184
+ yield Static(id="status")
185
+
186
+ def on_mount(self) -> None:
187
+ if self.record is None:
188
+ self.record = getattr(self.app, "_subagents", {}).get(self.child_id)
189
+ if self.record is None or self.record.parent_id != self.parent_id:
190
+ self.add_notice("Child session not found for this parent.", "error")
191
+ return
192
+ self._mount_transcript(UserMessage(self.record.prompt or "Child task"))
193
+ self._ready = True
194
+ self.refresh_record()
195
+
196
+ def _schedule_stream_flush(self, callback: Any) -> None:
197
+ self._stream_flush_callback = callback
198
+ if self._stream_flush_timer is not None:
199
+ return
200
+ def flush() -> None:
201
+ self._stream_flush_timer = None
202
+ pending = self._stream_flush_callback
203
+ self._stream_flush_callback = None
204
+ if pending is not None:
205
+ pending()
206
+ self._stream_flush_timer = self.set_timer(1 / 15, flush)
207
+
208
+ def on_unmount(self) -> None:
209
+ if self._stream_flush_timer is not None:
210
+ self._stream_flush_timer.stop()
211
+
212
+ def _set_status(self, _value: str = "") -> None:
213
+ self.query_one("#status", Static).update(render_footer(
214
+ self._ui_state, hint="esc back", workspace=display_workspace_path(self.workspace),
215
+ ), layout=False)
216
+
217
+ async def action_stop_child(self) -> None:
218
+ await self.app.cancel_subagent(self.child_id)
219
+
220
+ def refresh_record(self) -> None:
221
+ """Synchronize live child state into the nested transcript."""
222
+ record = self.record
223
+ if record is None or not self._ready:
224
+ return
225
+ self.query_one("#topbar", TopBar).set_context(
226
+ self.workspace,
227
+ record.model_id,
228
+ label=record.label,
229
+ )
230
+
231
+ self.query_one("#child-heading", Static).update(
232
+ f"{record.label} · {record.status} · {record.model_id}\n"
233
+ f"child {record.agent_id[:8]} ← parent {record.parent_id[:8]}"
234
+ )
235
+ for event_type, payload in record.events[self._event_cursor:]:
236
+ self._event_cursor += 1
237
+ if event_type in {"agent_spawned", "agent_completed", "agent_failed"}:
238
+ continue
239
+ if event_type == "run_started" and self._process is not None and self._process.completed:
240
+ self._thinking = self._process = self._assistant = self._reasoning = None
241
+ self._mount_transcript(UserMessage(str(payload.get("prompt") or "Continued child task")))
242
+ if event_type == "question_asked":
243
+ self.add_notice("Waiting for an answer in the parent session.")
244
+ continue
245
+ self._presenter.handle(event_type, payload)
246
+ # Child events may arrive in a burst while the parent screen is also
247
+ # being refreshed. Do not leave the child's final buffered delta
248
+ # waiting on a scheduler tick; the child view must reflect the record
249
+ # immediately after its refresh.
250
+ self._presenter.flush_stream_paints()
251
+ if record.status != "running":
252
+ if self._assistant is None and record.output_text:
253
+ self.set_assistant(record.output_text)
254
+ self.finish_assistant()
255
+ self.finish_reasoning()
256
+ if self._process is None or not self._process.completed:
257
+ self.finish_process(f"Child {record.status}")
258
+ self._ui_state.phase = "idle"
259
+ self._busy = False
260
+ self._set_status()
261
+
262
+
263
+ class SubagentWidget(ToolCallWidget):
264
+ """Clickable parent-transcript row for a child agent."""
265
+
266
+ keep_in_transcript = True
267
+
268
+ def __init__(self, call_id: str, tool_name: str) -> None:
269
+ self.record: Optional[SubagentRecord] = None
270
+ super().__init__(call_id, tool_name)
271
+ self.add_class("subagent-call")
272
+
273
+ def _tool_title(self) -> tuple[str, str]:
274
+ return ("Subagent", "↳")
275
+
276
+ def _summary(self) -> str:
277
+ return (
278
+ self.record.label
279
+ if self.record
280
+ else str(
281
+ self.arguments.get("label")
282
+ or self.arguments.get("prompt")
283
+ or self.raw_arguments
284
+ )
285
+ )
286
+
287
+ def _body_rows(self) -> list[Any]:
288
+ prompt = (
289
+ self.record.prompt
290
+ if self.record
291
+ else str(self.arguments.get("prompt") or "")
292
+ )
293
+ rows: list[Any] = []
294
+ if prompt:
295
+ rows.append(Text(clip_text(prompt, 240), style="#8aa8a5"))
296
+ rows.append(Text("↳ click to open child transcript", style="underline #7186c7"))
297
+ return rows
298
+
299
+ def bind(self, record: SubagentRecord) -> None:
300
+ self.record = record
301
+ if self not in record._widgets:
302
+ record._widgets.append(self)
303
+ self.refresh_content()
304
+
305
+ def refresh_content(self) -> None:
306
+ if self.record is not None:
307
+ self.status = {"completed": "done", "running": "running"}.get(self.record.status, "failed")
308
+ super().refresh_content()
309
+
310
+ def on_bash_tool_header_toggle(self, event: BashToolHeader.Toggle) -> None:
311
+ event.stop()
312
+ self.open_screen()
313
+
314
+ def open_screen(self) -> bool:
315
+ if self.record is None:
316
+ self.app.notify("Child is starting; its session will be available shortly.")
317
+ return False
318
+ self.app.open_subagent(self.record)
319
+ return True
320
+
321
+
322
+ class SubagentSurface:
323
+ """Child-agent event wiring mixed into CodingAgentApp."""
324
+
325
+ def _bind_spawn_widget(self, record: SubagentRecord) -> None:
326
+ if record.tool_call_id:
327
+ match = self._tools.get(record.tool_call_id)
328
+ if isinstance(match, SubagentWidget):
329
+ match.bind(record)
330
+ return
331
+ candidates = [
332
+ item
333
+ for item in self._tools.values()
334
+ if isinstance(item, SubagentWidget) and item.record is None
335
+ ]
336
+ if not candidates:
337
+ return
338
+ match = next(
339
+ (
340
+ item
341
+ for item in candidates
342
+ if (
343
+ not record.prompt
344
+ or item.arguments.get("prompt") == record.prompt
345
+ )
346
+ and (
347
+ not record.label
348
+ or item.arguments.get("label") in {record.label, None, ""}
349
+ )
350
+ ),
351
+ None,
352
+ )
353
+ if match is not None:
354
+ match.bind(record)
355
+
356
+ def _on_agent_spawned(self, payload: dict[str, Any]) -> None:
357
+ child_id = str(payload.get("child_id") or "")
358
+ if not child_id:
359
+ return
360
+ record = self._subagents.get(child_id) or SubagentRecord(
361
+ agent_id=child_id,
362
+ parent_id=str(payload.get("agent_id") or ""),
363
+ label=str(payload.get("label") or "subagent"),
364
+ prompt=str(payload.get("prompt") or ""),
365
+ model_id=str(payload.get("model_id") or ""),
366
+ )
367
+ record.tool_call_id = str(payload.get("tool_call_id") or record.tool_call_id)
368
+ record.session_id = str(payload.get("child_session_id") or child_id)
369
+ record.label = str(payload.get("label") or record.label)
370
+ record.prompt = str(payload.get("prompt") or record.prompt)
371
+ record.config = {key: payload.get(key) for key in ("model_id", "max_turns", "reasoning_effort")}
372
+ if child_id:
373
+ self._subagents[child_id] = record
374
+ self._bind_spawn_widget(record)
375
+ self._refresh_subagent_screen(record)
376
+ self._refresh_subagent_tasks()
377
+
378
+ def _on_agent_finished(self, event_type: str, payload: dict[str, Any]) -> None:
379
+ child_id = str(payload.get("child_id") or "")
380
+ record = self._subagents.get(child_id)
381
+ if record is None:
382
+ return
383
+ if payload.get("agent_id") and payload["agent_id"] != record.parent_id:
384
+ return
385
+ before = len(record.events)
386
+ record.ingest(event_type, payload)
387
+ if len(record.events) == before:
388
+ return
389
+ if getattr(self, "_pending_question_agent_id", "") == child_id:
390
+ from coding_agent.tui.composer import PromptInput, SlashMenu
391
+ self._pending_question_id = None
392
+ self._pending_question_agent_id = ""
393
+ self._pending_question_default = ""
394
+ self.query_one("#slash-menu", SlashMenu).set_commands(())
395
+ self.query_one("#approval-menu", SlashMenu).set_commands(())
396
+ prompt = self.query_one("#prompt", PromptInput)
397
+ prompt.submit_on_enter = False
398
+ prompt.disabled = self._busy
399
+ self._update_composer_hint()
400
+ self._refresh_subagent_widgets(record)
401
+ self._refresh_subagent_screen(record)
402
+ self._refresh_subagent_tasks()
403
+ self.add_notice(f"Subagent {record.label} {record.status} · Ctrl+G to inspect")
404
+
405
+ def _on_child_event(self, event_type: str, payload: dict[str, Any]) -> None:
406
+ agent_id = str(payload.get("agent_id") or "")
407
+ if not agent_id or not payload.get("parent_id"):
408
+ return
409
+ record = self._subagents.get(agent_id)
410
+ if record is None:
411
+ record = SubagentRecord(
412
+ agent_id=agent_id,
413
+ parent_id=str(payload.get("parent_id") or ""),
414
+ label="subagent",
415
+ prompt="",
416
+ model_id=str(payload.get("model_id") or ""),
417
+ )
418
+ if agent_id:
419
+ self._subagents[agent_id] = record
420
+ self._bind_spawn_widget(record)
421
+ record.ingest(event_type, payload)
422
+ self._refresh_subagent_widgets(record)
423
+ self._refresh_subagent_screen(record)
424
+
425
+ def _show_child_question(self, payload: Mapping[str, Any]) -> None:
426
+ """Surface child questions through the parent's interactive composer."""
427
+ if isinstance(self.screen, SubagentScreen):
428
+ from coding_agent.tui.screens.modal import dismiss_overlay
429
+
430
+ dismiss_overlay(self.screen)
431
+ self.call_after_refresh(self._show_question, dict(payload))
432
+ return
433
+ self._show_question(payload)
434
+
435
+ def _refresh_subagent_widgets(self, record: SubagentRecord) -> None:
436
+ """Repaint parent cards only when their visible status changes.
437
+
438
+ Child text and tool events can arrive continuously from many agents.
439
+ Their parent cards show status, not the child transcript, so refreshing
440
+ them on every event creates avoidable layout work.
441
+ """
442
+ for widget in record._widgets:
443
+ if isinstance(widget, SubagentWidget) and widget.record is record:
444
+ status = {"completed": "done", "running": "running"}.get(record.status, "failed")
445
+ if widget.status != status:
446
+ widget.refresh_content()
447
+
448
+ def _refresh_subagent_screen(self, record: SubagentRecord) -> None:
449
+ screen = self.screen
450
+ if isinstance(screen, SubagentScreen) and screen.child_id == record.agent_id:
451
+ screen.refresh_record()
452
+
453
+ def open_subagent(self, record: SubagentRecord) -> None:
454
+ self._subagents[record.agent_id] = record
455
+ self.push_screen(SubagentScreen(child_id=record.agent_id, parent_id=record.parent_id, workspace=self.workspace))
456
+
457
+ def action_subagents(self) -> None:
458
+ self.push_screen(SubagentTasksScreen())
459
+
460
+ def _refresh_subagent_tasks(self) -> None:
461
+ if isinstance(self.screen, SubagentTasksScreen):
462
+ self.screen.refresh_tasks()
463
+
464
+ async def cancel_subagent(self, child_id: str) -> None:
465
+ harness = getattr(self._agent, "harness", None)
466
+ record = getattr(harness, "child_tasks", {}).get(child_id)
467
+ if record is not None and record.task is not None and not record.task.done():
468
+ import asyncio
469
+ record.task.cancel()
470
+ await asyncio.gather(record.task, return_exceptions=True)
471
+
472
+ async def restore_subagents(self) -> None:
473
+ if self._agent is None:
474
+ return
475
+ store = self._agent.persistence
476
+ load = getattr(store, "load_children", None)
477
+ if not callable(load):
478
+ return
479
+ for metadata in await load(parent_session_id=self._agent.session_id):
480
+ child_id = metadata["child_id"]
481
+ if child_id in self._subagents:
482
+ continue
483
+ self._on_agent_spawned(metadata)
484
+ record = self._subagents[child_id]
485
+ for event_type, payload in await store.load_events(session_id=record.session_id):
486
+ record.ingest(event_type, payload)
487
+ record.status = metadata["status"]
488
+ record.output_text = metadata["output_text"] or record.output_text
489
+ if record.status == "running":
490
+ record.status = "interrupted"
491
+ self._refresh_subagent_widgets(record)
492
+
493
+
494
+ class SubagentTasksScreen(ModalBase[None]):
495
+ """Stable access to child sessions even after parent transcript compaction."""
496
+
497
+ CSS = """
498
+ SubagentTasksScreen { align: center middle; background: #000000 60%; }
499
+ #subagent-tasks { width: 90%; height: 70%; border: round #484F58; background: #0A0A0A; }
500
+ """
501
+
502
+ def compose(self):
503
+ yield OptionList(id="subagent-tasks")
504
+
505
+ def on_mount(self) -> None:
506
+ self.query_one(OptionList).border_title = "Subagents · Enter inspect · Esc back"
507
+ self.refresh_tasks()
508
+ self.query_one(OptionList).focus()
509
+
510
+ def refresh_tasks(self) -> None:
511
+ options = self.query_one(OptionList)
512
+ highlighted = options.highlighted
513
+ options.clear_options()
514
+ records = list(getattr(self.app, "_subagents", {}).values())
515
+ for record in records:
516
+ color = {"running": "#d7a84b", "completed": "#72a57a"}.get(record.status, "#d66b73")
517
+ label = Text(f"{record.status:12} ", style=color)
518
+ label.append(f"{record.label} · {record.model_id} · {record.agent_id[:8]}")
519
+ options.add_option(Option(label, id=record.agent_id))
520
+ if not records:
521
+ options.add_option(Option("No child sessions yet.", disabled=True))
522
+ else:
523
+ options.highlighted = min(highlighted or 0, len(records) - 1)
524
+
525
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
526
+ record = self.app._subagents.get(event.option.id)
527
+ if record is not None:
528
+ from coding_agent.tui.screens.modal import dismiss_overlay
529
+
530
+ dismiss_overlay(self)
531
+ self.app.call_after_refresh(self.app.open_subagent, record)