Scenario K pins the global_hooks wildcard forms (exact, <event>.*, *.<name>, *.*) and partial-glob inertness against a real binary; Scenario L proves --macro without --agent fires zero agent.* events.
The --macro dispatch arm now opens the top-level agent bracket before
macro_execute and closes it signal-aware afterwards, draining pending
hooks like the --execute arm; both bracket calls no-op without a loaded
agent, so agent-less macro runs are unaffected. The isolated arm of
macro_execute closes the bracket an in-macro .agent step opens on its
fresh context, on the step-failure path too. Binary-level tests pin
agent.started plus exactly one terminal event for the --agent flag and
for in-macro .agent steps in both isolation modes, on success and
failure.
Whitelist entries now admit hooks via <event>.* (every hook of that
event), *.<name> (that hook name on every event), and * / *.*
(everything), alongside the unchanged exact <event>.<name> form.
Partial globs like tool.st* are not wildcards: they admit nothing and
still land in the unknown-entry debug diagnostics, while <event>.*
entries are exempt from them. Escalation events keep resolving ungated
regardless of whitelist content.
- strip interior NUL bytes from hook env values in fire_resolved so
agent-authored text (e.g. escalation questions) cannot suppress a hook
via a failed spawn; raise the spawn-failure log to warn!
- make Supervisor::has_active_tasks recurse into child supervisors,
mirroring cancel_recursive, so a prompt ctrl-c that cancels only a
grandchild still latches as an interruption
- document the signal-first vs error-first classification asymmetry at
top_level_agent_finished / SpawnResultHooks::fire and the deliberate
REPL teardown cancel; pin ctrlc-with-no-error => agent.interrupted
- probe Scenario J: pty-driven ctrl-c during a live headless --agent run
fires exactly one agent.interrupted with no COYOTE_AGENT_ERROR
Ctrl-C at the reedline prompt no longer latches the abort signal
unconditionally: latch_prompt_interrupt only marks an interruption when
the supervisor had live agents/jobs to cancel (new
Supervisor::has_active_tasks), so the advertised exit path (ctrl-c at an
idle prompt, then ctrl-d) fires agent.completed at teardown instead of
agent.interrupted. Mid-turn ctrl-c still latches directly via
wait_user_interrupt/poll_abort_signal, preserving the
interrupted-then-exit classification.
Also: probe-lane tests for escalation.answered single-fire,
escalation question env truncation, agent.failed inverse gate, and
hooks probe Scenario I (session.started vs session.resumed); comment
notes on escalation.answered semantics and the agent__cancel blind
spot.
- escalation.raised fires in the child ctx after queue submit; escalation.answered
fires in the parent ctx from the taken request, so both agree on origin envs
- escalation.* resolves global hooks ungated: the per-agent global_hooks whitelist
never applies to these root-level events
- session.resumed fires on the use_session resume leg, inverse of session.started
- agent.interrupted replaces agent.completed/failed when the abort signal was
ctrl-c'd: top-level bracket (now abort-aware), spawned-subagent result arm
(agent__cancel / REPL ctrl-c), and graph agent nodes via the session signal;
exit_agent and the shell-exit path stay non-interrupted by passing no signal
- document the deliberate non-fire paths (direct prompts, escalation timeout and
parent-drop, graph approval nodes)
Two conformance gaps were tests-only:
- install_builtin_hooks warning: a #[serial] test proves the canonicalized
compare stays quiet when COYOTE_HOOKS_DIR merely respells the default
(via a '..' hop and, on unix, a symlink), and a second test proves a
genuine override still warns on a fresh install where the default hooks
dir does not exist (fallback compare).
- run_hook payload cleanup: a dispatch test fires a hook with a payload
and an unspawnable cwd, anchors on the spawn-failure debug log (and the
absence of a payload-write failure) so the assertion cannot pass
vacuously, then polls with a bounded deadline until the payload file is
unlinked.
Both tests were verified red-capable: reverting the canonicalization or
skipping the unlink on the error branch makes them fail.
Extends the .gitattributes eol=lf guard to assets/agents/** (33 embedded
shell scripts share the CRLF failure mode the hooks rules fix) and
scripts/*.sh. Adopts the probe-authored Scenario H: black-box checks of
the installed log-events.sh (XDG default, 0600, UTC header, secret
filter, symlink refusal) and notify.sh (control-byte-stripped fallback),
plus non-executable .builtin-manifest.
- one shared exec-bit predicate (Language-recognized extensions => 0755) used by
every hook/agent installer; non-script files in hooks dirs stay non-executable
- mixin discovery scans the global hooks dir and each agent's hooks dir;
.builtin-manifest is never scanned
- NTFS 8.3 short-name aliases rejected as builtin-manifest entries
- hook-script install announcements restricted to real hook locations
- COYOTE_HOOKS_DIR override warning compares canonicalized paths
- embedded assets decode via checked from_utf8 instead of from_utf8_unchecked
- log-events.sh defaults under ${XDG_STATE_HOME:-$HOME/.local/state}/coyote/hooks.log
keeping 0600/symlink-refusal/secret-filter hardening; notify.sh tty fallback strips
control bytes; both use UTC ISO-8601 timestamps
- payload files orphaned by a write failure are unlinked; stale coyote-hook-*.json
payloads swept best-effort at startup
- sweep_removed_agent_hooks never follows symlinked agent dirs
- usage-probe-hooks.sh replaces bare sleeps with a bounded poll, re-verifies 0755 on
restored scripts, and covers roles/agent hook locations
- TestConfigDirGuard hoisted into crate::testing
- .gitattributes pins LF line endings for bundled scripts
hooks is now a reserved bundle name.
- Thread an explicit InstallMode (Skip/Prompt/Force) through the hook
installers: startup install_builtins() is pinned to Skip and never
prompts (test-pinned on both mocked-TTY and TTY-less paths);
--install-builtins hooks runs Prompt mode across all three hook
locations (global hooks dir, roles/hooks, agents/<name>/hooks) under
one shared keep-all/replace-all sticky scope; force paths keep
unconditional overwrite semantics.
- Hoist the bundle resolve_conflict/StickyMode/ConflictAction prompt
into shared config::conflict with a NonInteractive policy: bundle
installs keep their non-TTY bail, builtin hook refreshes resolve
non-TTY conflicts to keep. Bundle UX preserved byte-for-byte,
pinned by tests.
- Extend .builtin-manifest reconcile to the resolved top-level hooks
dir (shipped set from HookAssets::iter()); malformed-manifest
fail-safe (zero deletions) preserved and test-pinned.
- Sweep removed builtin agents' hooks: agent dirs absent from the
embed with a hooks/.builtin-manifest reconcile against an empty
shipped set; empty hooks dirs are removed, user files and
manifest-less agent dirs are never touched.
- Update usage-probe-hooks.sh Scenario C to the new non-interactive
keep contract and add black-box Scenarios E-G covering role/agent
hook reconcile, top-level manifest fail-safety, and the
removed-agent sweep.
The look-don't-touch bypass was keyed on load_with_interpolation's
pre-existing bool, which mutating early paths (--authenticate, --auth-mcp,
mcp manage, vault flags) already passed as true meaning only 'skip vault
interpolation' — so they silently lost their first-run config creation.
Split the parameter: skip_interpolation keeps the historical meaning,
a distinct inspection flag alone gates the missing-config default/wizard
bypass.
inspection_only is now computed from an explicit read-only whitelist:
--sync-models is excluded (it downloads and writes models-override.yaml)
and returns to the bootstrap/strict path, keeping only its historical
interpolation-skip and vault leniency. mcp/vault inspection sub-conditions
are hoisted and shared so mutating flag combos never inherit the bypass.
Coverage: sandbox vault contract tests, a sync-models bootstrap-path pin,
a --sync-models scenario in usage-probe-inspection.sh, and a PTY wizard
regression probe (scripts/usage-probe-wizard.sh).
The harness served as one-time review evidence for the tool's attempt
chain; nothing in CI executes bash harnesses and the tool script rarely
changes, so it is removed rather than left to rot (user decision).
- Skip the builtins bootstrap, function-binary build, and tool-scope
rebuild for inspection-only runs (--info/--list-*/--mcp-list/
--list-secrets without --agent/--role): a pristine COYOTE_CONFIG_DIR
now stays byte-empty (was 269 files written before dispatch)
- Tolerate a model-less default config on the inspection surface via
AppConfig::from_config_lenient + Model::default() in bootstrap;
strict 'No available model' behavior unchanged for the REPL path and
all mutating mcp/vault flags
- --list-secrets degrades to an rc=0 informational message in sandbox
mode (mutating vault flags still fail there), pinned by test
- Add the 041.7-4 regression test: a malformed COYOTE_<NAME>_GLOBAL_TOOLS
env override logs the debug message and leaves global_tools untouched
- Adopt scripts/usage-probe-inspection.sh (10 flags: rc=0 + zero writes,
output required only for --info) and add binary-level
tests/inspection_flags.rs, incl. the agent+--info still-bootstraps pin
- openai searches run an attempt chain: the chat-completions mechanism
(search-preview model + web_search_options patch) first, then the
Responses-native web_search tool on a responses-capable model; the
search fails only when both attempts fail
- both wire-keyed patches (COYOTE_PATCH_OPENAI_CHAT_COMPLETIONS /
COYOTE_PATCH_OPENAI_RESPONSES) are exported per attempt; the client's
resolved wire applies exactly one
- supports_native_search accepts responses-capable openai families
(gpt-4o*/gpt-4.1*/gpt-5*/o3*/o4*)
- stub-coyote harness pins attempt order, patch export, model mapping,
hard failure, and untouched non-openai paths
(scripts/test-web-search-tool.sh)
- CHANGELOG: responses-default breakage caveat replaced with the
both-wire feature note
- unify hook base-env assembly on hooks::base_envs_parts (drop the
hand-copied hook_base_envs in function/agents.rs; graph executor and
spawn-result hooks now share the single construction site, test-pinned)
- factor the duplicated job.started dispatch in jobs.rs handle_start
into one fire_job_started helper
- COYOTE_LLM_ERROR now carries the immediate error (to_string()),
matching COYOTE_TOOL_ERROR; test pins the new format
- remove all stale #[allow(dead_code)] markers in src/hooks.rs
- test infra: rename install_warn_collector -> install_log_collector,
add poison-symmetric warn_snapshot/debug_snapshot helpers, hoist a
shared EnvVarGuard (TestConfigDirGuard now builds on it), add a
same-fire payload-uniqueness test, and give global_tools env
overrides the same malformed-JSON debug diagnostic as global_hooks
- inspection-only flags (--info et al) bypass the first-run wizard:
missing config + info_flag returns Config::default() without writing
anything, in both the sandbox and interactive arms (tests included)
- bound the shim-relocation test's exec in an ETXTBSY retry (10x50ms)
- document the intended 'roles' reserved-name asymmetry at
resolve_bundle_name's gate
The .compress-session success test polls through the whole summarization
call chain; those nested poll frames overflow the default test-thread
stack on Windows debug builds even with the outer future boxed. Drive
all four deep-compress tests through the same big-stack run_async
harness the macro tests already use.
Bundles: RemoteLayout.hooks + scan/is_empty/plan routing into hooks_dir,
a functional --filter hooks arm replacing the inert interim arm,
TopCategory::Hooks provenance rows, and per-file 'bundle defines hook
script' summary lines (visibility only, no approval prompt).
Reconciliation: new builtin_manifest module — a hidden per-directory
.builtin-manifest records the files the builtin installer actually owns
(written this run, previously owned, or pending failed deletions);
deletion is strictly manifest-minus-embed so user-created files in the
same directory are never touched. Wired into install_builtin_agents for
agents/<name>/hooks/ and a greenfield minimal Role::install_builtin_role_hooks
for roles/hooks/.
Hardening: bundles are barred from shipping any path component that
aliases .builtin-manifest (Unicode case fold + trailing dot/space,
directories included); manifest entries reuse the bundle store's
is_safe_component rules; malformed manifests fail safe toward keeping
files; role-side reconcile failures degrade to a warning like the
agent side.
Black-box scenarios against a built binary with dry_run configs: hook
cadence + env scoping, runtime inertness of failing hooks, --info firing
zero agent.* events, --install-builtins hooks install/force/cold-start
semantics, and end-to-end firing of the installed log-events.sh via the
documented relative-path wiring.
- AssetCategory::Hooks + InstallFilter::Hooks (NAMES/parse/clap), reserving
the 'hooks' bundle name via the existing AssetCategory::parse check
- HookAssets embed + install_builtin_hooks(force): installs the bundled
example scripts 0755 into paths::hooks_dir() via write_file_atomic, wired
into install_builtins() and install_assets(); warns once when
COYOTE_HOOKS_DIR diverges from the config-dir default
- assets/hooks/notify.sh: notify-send -> osascript -> terminal fallback,
with AppleScript-literal sanitization of model-influenced values
- assets/hooks/log-events.sh: appends a COYOTE_* env snapshot (secrets
excluded, 0600 log, symlink-refusing) to ${COYOTE_HOOK_LOG:-/tmp/coyote-hooks.log}
- sbx-mixin discovery now scans roles/*.sbx-mixin.yaml and
roles/hooks/*.sbx-mixin.yaml (mirroring the role-hook cwd)
- tests: install+0755+force, install_assets(Hooks) end-to-end, discover()
roles scanning, reserved bundle name, no-ARGC-BUILD embed content, script
content order + real execution (secret exclusion, tty-less fallback),
CLI parser round-trips; hooks_dir env test made #[serial]
- Open the top-level agent bracket only at committed dispatch sites so
inspection-flag early returns (--list-sessions, --info, --macro,
--rebuild-rag, --init-*, --build-tools) never leak agent.started
- Close the agent bracket ahead of process::exit in shell_execute's
execute branch, where the dispatch wrapper's close is unreachable
- Extract the ask-loop auto-compression seam and cover the
session.compressed success path at both call sites with sink tests
- Prove Session.role_hooks never serializes and that injected
role_hooks YAML is ignored on deserialization
Turn bracket (repl::TurnBracket): turn.started on entry and exactly one of
turn.{completed,interrupted,failed} on exit, with the interrupted-vs-failed
discrimination (abort_signal.aborted_ctrlc()) in one place. Wired at the
three outer boundaries: ask(), start_directive, and shell_execute — never at
the per-LLM-round before/after_chat_completion seams.
Session seams: session.started fires for newly created sessions only
(use_session's new_from_ctx paths; resume fires nothing), session.ended in
exit_session while the session is still attached, session.compressed on the
success path of both compress_session call sites (auto-compression and the
.compress command).
Session-held role hooks: Session snapshots the role's HooksMap on set_role,
clears it on clear_role/sync_agent, and re-reads it from the role file on
load; resolved_hooks gains a session leg so role hooks keep firing when the
role lives in the session (fresh set_role and resumed sessions alike).
Top-level agent bracket: agent.started/completed/failed once per agent RUN —
headless --agent dispatch and the interactive agent-context lifetime (enter
via .agent or startup; exit via switch-away or REPL teardown), never per
message. COYOTE_AGENT_ID is synthesized as top-<agent-name>-<nonce>.
Exit-path seams call hooks::drain_pending with a 2s bound so exit hooks
finish spawning before process teardown; interactive mid-session events
never drain.
Wire the hook engine into the three runtime seams:
- tool.*: ToolCall::eval fires tool.started once the name and arguments
are resolved, then exactly one of tool.completed/tool.failed,
discriminated on both the Err path and tool_call_error-shaped outputs.
The arguments JSON is handed to the engine as the payload; eval itself
performs no payload-file I/O.
- llm.request.*: the three transport free functions
(call_chat_completions, call_chat_completions_streaming, and the quiet
graph-node variant) fire started/completed/failed for every provider
and caller, classifying failures as aborted, api_error (with
COYOTE_LLM_STATUS), or other.
- job.*: handle_job_tool fires job.started before either spawn and the
terminal event inside the detached closures via a pre-resolved
fire_resolved snapshot, so job__start's return is never delayed.
The test sink now captures the payload and cwd of each dispatch
(sink-only change), and seam tests pin event cadence, env contracts, and
byte-identical tool results and errors.
Two test gaps: no test fired a genuine graph.node.failed and inspected
its payload (the only node-failure test asserted the hook's absence
because a fallback absorbed the error), and no test drove a malformed
hook definition through the new graph seams. The first test fails an
agent node with no fallback and asserts the captured envs carry the
node id, node type, a numeric duration, and a non-empty error while
the failure still propagates out of the executor. The second routes a
blank-command hook from graph.yaml's top level through the executor
seam and proves it degrades safely: the node completes, the malformed
hook is skipped with a debug log, and a well-formed sibling on the
same event still fires.
Fire agent.started before the supervisor spawn and agent.completed/failed
from the spawn task's result arms, resolved against the child context so
global hooks stay gated by the child's own whitelist. Graph AgentNode
failures fire agent.failed at the point of failure, before a declared
fallback converts them into a successful route. The graph executor fires
graph.node.started before each branch spawn and graph.node.completed/failed
beside the narrate calls, with per-branch pre-resolved snapshots and
metadata so parallel super-step branches never cross-contaminate. Base
envs for detached dispatches are rebuilt at fire time via a shared
hook_base_envs helper.
Introduce src/hooks.rs as a types-only module (HookDef, HooksMap) and
plumb hooks:/global_hooks: config fields through every scope:
- Config + AppConfig hooks field, both Default impls, the manual
from_config copy, and a COYOTE_HOOKS env override
- Role frontmatter: hand-parsed hooks arm, private field, getter, and
JSON-inline re-serialization in Role::export (malformed maps degrade
to None)
- AgentConfig + Graph: hooks + global_hooks on both structs, copied in
AgentConfig::from_graph, with a per-agent global_hooks env override
- paths::hooks_dir() = <config_dir>/hooks with COYOTE_HOOKS_DIR override
- Commented hooks: block in the global config template (global scope
only; no global_hooks key)
No engine or dispatch logic yet; existing configs parse unchanged.
Review findings: the streaming arm now mirrors the non-streaming twin
(openai_extract_responses): partial text/tool calls are returned rather
than discarded by a hard error; only an empty truncated response bails.
Billed usage on truncated turns is recorded before either outcome.
Also: CHANGELOG notes the streaming-incomplete surfacing and corrects
the include/strict phrasing (sent on every responses-wire body).
Add responses sub-patches to the six o-series quirks entries (temperature
and top_p removal; reasoning.effort high for the -high aliases) and
regenerate models.yaml from live models.dev/openrouter data, picking up
expected upstream price and model drift.
Parse Responses-API token usage on both wires: non-streaming responses
fill ChatCompletionsOutput.usage and response.completed events feed the
SseHandler accumulator, with cached input mapped from
input_tokens_details.cached_tokens. Harden failure paths: bail with the
payload message on response.failed and error stream events, and surface
incomplete_details.reason when a non-streaming response comes back
incomplete and empty.
Replay hardening: id-only Reasoning blocks (no encrypted_content) are
dropped at replay time since the API 400s on them under store:false,
which would permanently wedge a persisted session; a null summary is
normalized to [] at the same site. Claude's replay guards flip to a
positive Thinking/RedactedThinking allow-list.
Flip the default wire for stock openai (api.openai.com, no custom
api_base) to Responses. The resolver call site now passes a genuine
stock-openai predicate: api-key clients or stock oauth without api_base
qualify, config-oauth gateways never do. Custom api_base and
openai-compatible clients keep defaulting to chat; explicit wire_api
still always wins. Also reject a non-map responses section in
RequestPatch via test.
- assert old Thinking/RedactedThinking blocks re-serialize to the
original wire shape, proving both legs of old-session compat
- add serde_yaml round-trip test for Vec<ThinkingBlock> covering the
on-disk session format, including a Reasoning block with summary
payload and the encrypted_content: None omitted-field case
Add ThinkingBlock::Reasoning to carry OpenAI Responses reasoning items
verbatim. The Responses body builder now requests
reasoning.encrypted_content, marks tool defs strict:false, and replays
each round's reasoning items ahead of the assistant text and function
calls. Both extraction paths (non-streaming and streaming) capture
reasoning output items. Claude and Bedrock replay paths drop the
OpenAI-only variant so mid-session model switches keep working.
Completes the loop-tracker exemption change (8be9c8e) riding this
branch: the explanatory comment above LOOP_TRACKER_EXEMPT_TOOLS was
dropped in that commit; restore it, updated for the new entries.
Introduce a WireApi enum (responses|chat) with an optional wire_api
config field on the openai and openai-compatible clients, resolved
through a single resolve_wire_api fn: codex forces the responses wire
(an explicit wire_api: chat there is a hard config error), an explicit
wire_api wins everywhere else, and the default stays chat for now.
The openai dispatch generalizes from (RequestData, bool) to
(RequestData, WireApi), and the openai-compatible client gains opt-in
Responses dispatch (URL, body, extractor, and streaming forks).
Both patch layers are now wire-aware: RequestData carries its wire,
RequestPatch gains a responses section, and patch_request_data selects
patches per wire. This deliberately FIXES a codex bug: chat-shaped
model patches (e.g. o3-high's max_tokens/temperature/top_p/
reasoning_effort tweaks) previously merged wire-blind into codex
responses bodies, poisoning them with chat-only keys. On the responses
wire only an explicit responses sub-patch applies; chat-wire patching
is byte-identical to before (pinned by a regression test).