zjcode 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 (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. zjcode-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,348 @@
1
+ """Lightweight hook dispatch for external tool integration.
2
+
3
+ Loads hook configuration from `~/.deepagents/hooks.json` and fires matching
4
+ commands with JSON payloads on stdin. Subprocess work is offloaded to a
5
+ background thread so the caller's event loop is never stalled. Failures are
6
+ logged but never bubble up to the caller.
7
+
8
+ Config format (`~/.deepagents/hooks.json`):
9
+
10
+ ```json
11
+ {"hooks": [{"command": ["bash", "adapter.sh"], "events": ["session.start"]}]}
12
+ ```
13
+
14
+ If `events` is omitted or empty the hook receives **all** events.
15
+
16
+ Onboarding emits `user.name.set` with `{"name": "...", "assistant_id": "..."}`
17
+ after the user submits a non-empty preferred name.
18
+
19
+ `tool.use` fires before a tool call once its streamed arguments parse into a
20
+ complete value *and* its tool-call id is known; a call whose arguments never
21
+ parse, or that carries no id, is skipped. `tool.result` fires after every tool
22
+ call reaches a terminal state — successful execution, failure, or HITL
23
+ rejection/cancellation. The three blocks below show the payload *shapes*, not a
24
+ single sequence of events:
25
+
26
+ ```jsonc
27
+ {"event": "tool.use", "tool_name": "write_file", "tool_id": "toolu_abc123",
28
+ "tool_args": {"file_path": "src/foo.py", "content": "..."}}
29
+
30
+ {"event": "tool.result", "tool_name": "write_file", "tool_id": "toolu_abc123",
31
+ "tool_args": {"file_path": "src/foo.py", "content": "..."},
32
+ "tool_status": "success", "tool_output": "Updated file src/foo.py"}
33
+
34
+ {"event": "tool.error", "tool_names": ["write_file"]}
35
+ ```
36
+
37
+ `tool_args` is the parsed tool-call arguments; a non-object value (rare) is
38
+ wrapped as `{"value": ...}`. `tool_output` is the tool's returned content,
39
+ capped to `HOOK_TOOL_OUTPUT_LIMIT` characters (`tool_args` is not truncated); a
40
+ capped value ends with `…[output truncated]` so a consumer can tell a truncated
41
+ result from a short one.
42
+ `tool_status` is `"success"` or `"error"`; `"error"` covers both a tool that
43
+ raised and a call the user rejected or cancelled. Whenever a `tool.result` has
44
+ `tool_status: "error"`, `tool.error` (payload `{"tool_names": [<name>]}`) fires
45
+ alongside it, so existing `tool.error` hooks are unaffected.
46
+
47
+ `tool_args` is `{}` whenever a `tool.result` cannot be correlated back to a
48
+ `tool.use` — either because the call carried no id (then `tool_id` is `null`) or
49
+ because no `tool.use` fired for it (e.g. its args never parsed), in which case
50
+ `tool_id` may still be the real string id.
51
+
52
+ Ordering: the tool events (`tool.use`, `tool.result`, `tool.error`) are
53
+ dispatched fire-and-forget (see `dispatch_hook_fire_and_forget`) and every
54
+ matching hook command runs in its own subprocess. A `tool.use` is *dispatched*
55
+ before its `tool.result`, but the two run concurrently, so a hook subscribed to
56
+ both may observe them out of order, and events from parallel tool calls
57
+ interleave freely. Correlate by `tool_id` rather than relying on arrival order —
58
+ there is no cross-event delivery-ordering guarantee for the tool events. Most
59
+ non-tool events (`session.start`, `task.complete`, `session.end`, `user.prompt`,
60
+ `context.offload`, `context.compact`, `permission.request`) fire in program order.
61
+ They are dispatched with an awaited `dispatch_hook`, except `session.end` on the
62
+ interactive TUI, which is dispatched synchronously via `_dispatch_hook_sync` at
63
+ shutdown (the event loop is already tearing down); that path is still blocking and
64
+ in-order, so the program-order guarantee holds. `input.required` and
65
+ `user.name.set` are the exceptions with no program-order guarantee:
66
+ `user.name.set` is always dispatched fire-and-forget, and `input.required` is
67
+ fire-and-forget on the headless surface (awaited only in the interactive TUI).
68
+ """
69
+
70
+ from __future__ import annotations
71
+
72
+ import asyncio
73
+ import json
74
+ import logging
75
+ import subprocess # noqa: S404
76
+ from concurrent.futures import ThreadPoolExecutor
77
+ from typing import TYPE_CHECKING, Any
78
+
79
+ if TYPE_CHECKING:
80
+ from collections.abc import Mapping
81
+
82
+ logger = logging.getLogger(__name__)
83
+
84
+ HOOK_TOOL_OUTPUT_LIMIT = 2000
85
+ """Max characters of `tool_output` included in `tool.result` hook payloads.
86
+
87
+ Bounds payload size (data-amplification guard) while keeping enough of the
88
+ tool's output to be useful to audit/notification hooks. Applied in the single
89
+ shared builder `_tool_stream.build_tool_result_payload`, which both the
90
+ interactive and headless dispatch paths call, so the cap never drifts between
91
+ them. Only `tool_output` is capped; `tool_args` is passed through in full so hooks
92
+ that act on the arguments (e.g. a linter reading a `write_file` `content`) see
93
+ the exact value the tool received.
94
+ """
95
+
96
+ HOOK_SUBPROCESS_TIMEOUT = 5
97
+ """Seconds a single hook subprocess may run before it is killed.
98
+
99
+ Bounds how long one misbehaving hook can block the dispatch thread. Consumed in
100
+ code by the `subprocess.run` timeout and its timeout log message here, so those
101
+ two never drift. `app.py`'s graceful-exit comment names this symbol (rather than
102
+ a bare literal) so its prose can't go stale; note the drain itself is bounded
103
+ separately by `_GRACEFUL_EXIT_WAIT_SECONDS`, not by this value — a hook can run
104
+ up to this long while the aggregate drain gives up sooner.
105
+ """
106
+
107
+ _hooks_config: list[dict[str, Any]] | None = None
108
+ """Cached config — loaded lazily on first dispatch."""
109
+
110
+ _background_tasks: set[asyncio.Task[None]] = set()
111
+ """Strong references to fire-and-forget tasks to prevent GC."""
112
+
113
+
114
+ def _load_hooks() -> list[dict[str, Any]]:
115
+ """Load and cache hook definitions from the config file.
116
+
117
+ Returns:
118
+ An empty list when the file is missing or malformed so that normal
119
+ execution is never interrupted.
120
+ """
121
+ global _hooks_config # noqa: PLW0603
122
+ if _hooks_config is not None:
123
+ return _hooks_config
124
+
125
+ from deepagents_code.model_config import DEFAULT_CONFIG_DIR
126
+
127
+ hooks_path = DEFAULT_CONFIG_DIR / "hooks.json"
128
+
129
+ if not hooks_path.is_file():
130
+ _hooks_config = []
131
+ return _hooks_config
132
+
133
+ try:
134
+ data = json.loads(hooks_path.read_text())
135
+ if not isinstance(data, dict):
136
+ logger.warning(
137
+ "Hooks config at %s must be a JSON object, got %s",
138
+ hooks_path,
139
+ type(data).__name__,
140
+ )
141
+ _hooks_config = []
142
+ return _hooks_config
143
+ hooks = data.get("hooks", [])
144
+ if not isinstance(hooks, list):
145
+ logger.warning(
146
+ "Hooks config 'hooks' key at %s must be a list, got %s",
147
+ hooks_path,
148
+ type(hooks).__name__,
149
+ )
150
+ _hooks_config = []
151
+ return _hooks_config
152
+ _hooks_config = hooks
153
+ except (json.JSONDecodeError, OSError) as exc:
154
+ logger.warning("Failed to load hooks config from %s: %s", hooks_path, exc)
155
+ _hooks_config = []
156
+
157
+ return _hooks_config
158
+
159
+
160
+ def _run_single_hook(command: list[str], event: str, payload_bytes: bytes) -> None:
161
+ """Execute a single hook command, writing the JSON payload to its stdin.
162
+
163
+ On timeout `subprocess.run` kills and reaps only the direct hook process
164
+ (its `Popen.kill()` targets that one PID, never the process group), so any
165
+ grandchildren it spawned are left as orphans regardless — the timeout bounds
166
+ the hook process, not its whole descendant tree. `start_new_session=True`
167
+ does not change that; it only isolates the hook (and its descendants) into
168
+ their own session so a signal to our group doesn't reach them.
169
+
170
+ Args:
171
+ command: The command and arguments to run.
172
+ event: Event name (for logging).
173
+ payload_bytes: JSON payload to write to the command's stdin.
174
+ """
175
+ try:
176
+ subprocess.run( # noqa: S603
177
+ command,
178
+ input=payload_bytes,
179
+ stdout=subprocess.DEVNULL,
180
+ stderr=subprocess.DEVNULL,
181
+ start_new_session=True,
182
+ timeout=HOOK_SUBPROCESS_TIMEOUT,
183
+ check=False,
184
+ )
185
+ except subprocess.TimeoutExpired:
186
+ logger.warning(
187
+ "Hook command timed out (>%ss) for event %s: %s",
188
+ HOOK_SUBPROCESS_TIMEOUT,
189
+ event,
190
+ command,
191
+ )
192
+ except (FileNotFoundError, PermissionError) as exc:
193
+ logger.warning("Hook command failed for event %s: %s — %s", event, command, exc)
194
+ except Exception:
195
+ # Unexpected failure (e.g. ENOEXEC for a non-executable hook file, an
196
+ # embedded null byte, or fd/memory exhaustion). These are the failures
197
+ # we understand least, so surface them at warning — the expected
198
+ # timeout / not-found / permission cases above are also warnings, and a
199
+ # silent debug here would hide a hook that never fires.
200
+ logger.warning(
201
+ "Hook dispatch failed unexpectedly for event %s: %s",
202
+ event,
203
+ command,
204
+ exc_info=True,
205
+ )
206
+
207
+
208
+ def _dispatch_hook_sync(
209
+ event: str, payload_bytes: bytes, hooks: list[dict[str, Any]]
210
+ ) -> None:
211
+ """Dispatch matching hooks, running them concurrently via a thread pool.
212
+
213
+ Iterates over all configured hooks, skipping those whose event filter
214
+ does not match or whose `command` is missing/invalid. Matching hooks are
215
+ executed concurrently, each bounded by `HOOK_SUBPROCESS_TIMEOUT` per command.
216
+ Errors are caught per-hook and logged without propagating.
217
+
218
+ Args:
219
+ event: Dotted event name (e.g. `'session.start'`).
220
+ payload_bytes: JSON payload to write to each command's stdin.
221
+ hooks: List of hook definition dicts from the config file.
222
+ """
223
+ matching: list[list[str]] = []
224
+ for hook in hooks:
225
+ command = hook.get("command")
226
+ if not isinstance(command, list) or not command:
227
+ # A misconfigured `command` (missing, a bare string instead of an
228
+ # argv list, or empty) means this hook can never fire. Warn rather
229
+ # than silently skip so the config mistake is greppable instead of
230
+ # looking like the hook simply never matched.
231
+ logger.warning(
232
+ "Skipping hook with invalid `command` for event %s: %r",
233
+ event,
234
+ command,
235
+ )
236
+ continue
237
+
238
+ events = hook.get("events")
239
+ # Empty/missing events list means "subscribe to everything".
240
+ if events and event not in events:
241
+ continue
242
+
243
+ matching.append(command)
244
+
245
+ if not matching:
246
+ return
247
+
248
+ if len(matching) == 1:
249
+ _run_single_hook(matching[0], event, payload_bytes)
250
+ return
251
+
252
+ with ThreadPoolExecutor(max_workers=len(matching)) as pool:
253
+ futures = [
254
+ pool.submit(_run_single_hook, cmd, event, payload_bytes) for cmd in matching
255
+ ]
256
+ for future in futures:
257
+ future.result()
258
+
259
+
260
+ async def dispatch_hook(event: str, payload: Mapping[str, Any]) -> None:
261
+ """Fire matching hook commands with `payload` serialized as JSON on stdin.
262
+
263
+ The `event` name is automatically injected into the payload under the
264
+ `"event"` key so callers don't need to duplicate it.
265
+
266
+ The blocking subprocess work is offloaded to a thread so the caller's
267
+ event loop is never stalled. Matching hooks run concurrently, each bounded
268
+ by `HOOK_SUBPROCESS_TIMEOUT`. Errors are logged and never propagated.
269
+
270
+ Args:
271
+ event: Dotted event name (e.g. `'session.start'`).
272
+ payload: Arbitrary JSON-serializable mapping sent on the command's stdin.
273
+ """
274
+ try:
275
+ hooks = _load_hooks()
276
+ if not hooks:
277
+ return
278
+
279
+ # `default=str` degrades a non-JSON-serializable value (e.g. a
280
+ # provider-delivered whole-value arg object) to its string form rather
281
+ # than raising and dropping the entire hook event — the invocation stays
282
+ # auditable even if one field isn't natively serializable.
283
+ payload_bytes = json.dumps({"event": event, **payload}, default=str).encode()
284
+ await asyncio.to_thread(_dispatch_hook_sync, event, payload_bytes, hooks)
285
+ except Exception:
286
+ logger.warning(
287
+ "Unexpected error in dispatch_hook for event %s",
288
+ event,
289
+ exc_info=True,
290
+ )
291
+
292
+
293
+ def dispatch_hook_fire_and_forget(event: str, payload: Mapping[str, Any]) -> None:
294
+ """Schedule `dispatch_hook` as a background task with a strong reference.
295
+
296
+ Use this instead of bare `create_task(dispatch_hook(...))` to prevent the
297
+ task from being garbage collected before completion.
298
+
299
+ Safe to call from sync code as long as an event loop is running.
300
+
301
+ Args:
302
+ event: Dotted event name (e.g. `'session.start'`).
303
+ payload: Arbitrary JSON-serializable mapping sent on the command's stdin.
304
+ """
305
+ try:
306
+ loop = asyncio.get_running_loop()
307
+ except RuntimeError:
308
+ # A dropped hook is an audit/notification gap, so surface it at warning
309
+ # rather than debug. In the streaming paths a loop is always running, so
310
+ # this fires only from an unexpected sync call site.
311
+ logger.warning("No running event loop; skipping hook for %s", event)
312
+ return
313
+ task = loop.create_task(dispatch_hook(event, payload))
314
+ _background_tasks.add(task)
315
+ task.add_done_callback(_background_tasks.discard)
316
+
317
+
318
+ def has_pending_hooks() -> bool:
319
+ """Return whether fire-and-forget hook tasks are still in flight."""
320
+ return any(not task.done() for task in _background_tasks)
321
+
322
+
323
+ async def drain_pending_hooks() -> None:
324
+ """Await all in-flight fire-and-forget hook tasks.
325
+
326
+ Call this before the event loop tears down (e.g. at the end of a headless
327
+ run driven by `asyncio.run`) so background dispatches — most importantly the
328
+ final `tool.result` — are not cancelled mid-flight and silently dropped.
329
+ Each task's exceptions are already swallowed inside `dispatch_hook`, and any
330
+ stragglers are collected with `return_exceptions=True`, so this never
331
+ raises.
332
+
333
+ Precondition: this snapshots the in-flight set once and awaits it, so any
334
+ hook scheduled *after* the snapshot (during the await) is not drained. Call
335
+ it only once no further dispatches are possible. The headless caller invokes
336
+ it after `_run_agent_loop` has fully returned. The `app.py` graceful-exit
337
+ caller cancels the agent worker first, whose cancel handler
338
+ (`_handle_interrupt_cleanup`) schedules its terminal `tool.result` hooks
339
+ *synchronously* before this snapshot runs — see the ordering comment there —
340
+ so they are captured; a hook scheduled after a slow async write would not
341
+ be.
342
+ """
343
+ # Snapshot: tasks remove themselves from the set via their done-callback as
344
+ # they finish, so iterating the live set while gathering would mutate it.
345
+ pending = list(_background_tasks)
346
+ if not pending:
347
+ return
348
+ await asyncio.gather(*pending, return_exceptions=True)