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.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- zjcode-0.0.1.dist-info/entry_points.txt +2 -0
deepagents_code/hooks.py
ADDED
|
@@ -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)
|