- 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).
- 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
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.
- 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]
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.
Agent nodes marked teammates: true whose sub-agents run concurrently (map fan-out branches or multiple flagged agent nodes in one super-step) now form a team: each member gets a pre-provisioned inbox in a messaging-only PeerRegistry before any branch starts, a teammate roster injected into its prompt, peer resolution in agent__send_message, and a peers section in agent__list_running. Registries are scoped to a single fan-out and carry no lifecycle semantics: outputs still flow exclusively through collect_into/state reducers, finished peers reject new messages, and nothing registers in a supervisor. Also hardens the agent-recursion depth guard to work without a supervisor and adds validator warnings for zero-valued orchestration limits.
Detached tokio::process runner with a frozen JobEnvSnapshot (env-derived
bin dirs, vault-interpolated agent envs, COYOTE_TOOL_TIMEOUT resolved at
start), process_group(0) with pgid-guarded SIGTERM/SIGKILL escalation,
capture-only ring-buffer telemetry, and LLM_OUTPUT read after wait().
MCP jobs snapshot a single-entry McpRuntime holding only the validated
server and render through the same free fn as the foreground path.
job__start enforces its gates synchronously before any spawn:
jobs_enabled, the backgroundable whitelist with directionality teaching
errors, the per-request declared-names stash captured in
before_chat_completion, then capacity (lazy supervisor get-or-init in
plain sessions). job__check/list read the shared JobState cell without
consuming; job__collect blocks with the escalation early-out and applies
a tail-biased char-boundary cap plus optional tail_lines; job__cancel
kills the group with a 5s grace.
Job declarations are injected iff jobs are enabled at agent init, the
plain-session function-init sites, and the exit_agent rebuild; job__ is
carved out of enabled_tools filtering and excluded from
concrete_tool_names so REPL toggles cannot grant or revoke it.
Implements T1 of plans/background-jobs-design.md (§6, R7/R8/R9):
- Supervisor.handles is now HashMap<String, TaskHandle> where
TaskHandle = Agent(AgentHandle) | Job(JobHandle); agent-facing
accessors (active_count, effective_active_count, is_finished, take,
inbox, abort_signal_for, list_agents) match only Agent variants,
preserving all existing external behavior byte-for-byte.
- New JobHandle/JobState/JobStatus/JobResult types with pgid-guarded
process-group kill discipline: Drop and cancel_all/cancel_recursive
kill the group only while state.pgid is still set (pid-reuse guard),
via libc::killpg on unix and JoinHandle::abort elsewhere.
- Per-kind job capacity: Supervisor carries max_concurrent_jobs
(builder-set, default 0); job registration rejects at capacity.
- Cross-kind teaching errors at the four agent-lookup miss sites
(agent__check/collect/cancel/send_message) when the id is a
registered job or job_-prefixed; genuinely-unknown ids keep their
existing messages.
- Supervisor init condition is now can_spawn_agents || jobs_enabled in
use_agent and both child-agent spawn paths, with agent capacity 0 in
jobs-only contexts; use_agent cancels the old supervisor recursively
before replacing it.
- max_concurrent_jobs config plumbing: global Config field, AgentConfig
override + accessor, all four AppConfig touch points including the
COYOTE_MAX_CONCURRENT_JOBS env override; shared
effective_max_concurrent_jobs/jobs_enabled predicates
(agent override -> global -> default 5; 0 disables).
- Stage dependency-free RingBuf (64 KiB default) in src/function/jobs.rs
for the upcoming job output pump.
- New sanctioned dependency: libc 0.2 under cfg(unix).
Emit an mcp_prompt_<server> declaration for servers advertising the
prompts capability, execute prompts via McpRuntime::prompt on both tool
dispatch chains, and return the flattened prompt text as the tool
result. Sanitize server-controlled prompt names, descriptions, and
argument names before terminal rendering, and attribute the .prompt
argument inquire label to its server and prompt.
Per plans/mcp-resources-prompts-design.md §5.2 (T7).
Implements plans/mcp-resources-prompts-design.md §5.1/§5.4 (T6):
- .prompt <server> <name> [key=value ...] fetches an MCP prompt and
submits the result as chat input via Input::from_str + ask(), never
through REPL line parsing; GetPromptResult messages are flattened
into one user-role block with unconditional [user]/[assistant] labels
- missing required prompt arguments are collected interactively
- .list prompts renders server/name/description/args via the unified
catalog (CatalogItem gains an arguments field), degrading per server
- staged live tab-completion: enabled+running+prompts-capable servers
(no RPC), then live prompt names, then key= argument suggestions with
(required) markers; 2s timeout per RPC, all errors degrade to silent
empty suggestions, ctx read guard dropped before blocking
- the enabled-server alias expansion is factored into a shared helper
used by both tool-scope rebuild and completion
- BREAKING: the former .prompt <text> temp-role builtin is renamed to
.temp-role <text> (behavior preserved); .prompt now belongs to MCP
prompts, and a user macro named prompt or temp-role is shadowed
Per-server McpServerFeatures (tools fail-open, resources/prompts
fail-closed) now drive which meta-functions are declared, with
gated_meta_function_prefixes as the single gating seam; read/prompt
declarations land together with their handlers. The server-enablement
sentinel keys on the always-emitted search name so resources-only
servers survive role filtering.
Implements plans/mcp-resources-prompts-design.md §4.4/D7 (T5).
.install --help and .uninstall --help print a usage guide covering the
owner/repo shorthand, --git-host, --filter, --force, ref pinning, and
the bundle lifecycle; both usage error lines now point at --help. Tab
completion offers --help for both commands and --git-host on the first
.install argument, and the unified install parser accepts flags in any
argument position so completed flags work wherever they are inserted.
The empty .list bundles message now shows the REPL install form
alongside the CLI one.
--install <git-url|name> is the single entry point for remote installs
and updates; the unified .install dispatch likewise replaces
.install remote. Flag completion for .install now applies to the
unified form.
--install now takes a git URL or an installed bundle name: categories
are redirected to the new --install-builtins, installed names become
implicit updates, and source-shaped values install remotely. The old
--install-from keeps its exact behavior as a hidden deprecated alias.
The REPL's .install gains the same unified dispatch while keeping
.install <category> and .install remote <url> back-compat.
Updates re-clone a bundle's recorded source (honoring a recorded commit
pin unless a #<ref> override moves it), silently refresh files the bundle
owns that the user never modified, and fall back to the normal conflict
prompts for modified or unowned files. Files the remote no longer ships
are offered for deletion (kept by default non-interactively, staying
owned). The record is refreshed with the new commit, version, and
metadata, and stamped with an updated_at timestamp on success.
Implements the invocation and management surfaces from
plans/custom-commands-design.md §4 and §6:
- Top-level dispatch: an enabled macro <name> now runs as ".<name> [args]"
from the command catch-all; runtime-disabled macros point at
".macro enable <name>", locked macros name the owning config, and
unknown commands keep the existing error verbatim
- .macro enable|disable <name>: runtime toggles over the in-memory
global-level enabled_macros list (disable with no list materializes
all-active-minus-name); toggles error when a role/agent/session
allowlist owns the field
- .set enabled_macros <csv|null> with workspace-then-global existence
validation; .set key completion gains enabled_macros and the
previously missing enabled_skills
- Dynamic completion: enabled macros (with descriptions) join built-ins
on ".<TAB>" without touching the static command registry;
".macro <TAB>" lists invocable macros (incl. built-in-shadowed ones)
plus the enable/disable subcommands; second-arg completion offers
toggle-eligible names
- .list macros: enriched table (name, source, isolated, state,
description) covering every resolver state incl. missing and
shadowed rows; .help gains a custom-commands section
- Session info/render and sysinfo display enabled_macros; Macro::load
resolves workspace-then-global; enable/disable rejected as macro
names in the creator
Adds src/config/macro_policy.rs: MacroPolicy::effective computes the
visible macro set on demand from the discovered definition files, the
four-level enabled_macros allowlists, and the built-in command names.
- Discovery scans workspace (.coyote/macros/) then global macros dirs on
every resolution; workspace shadows global by name, and the shadowed
global entry is retained and flagged so both stay listable (plan
custom-commands-design.md §5). Workspace scanning is gated on a bool
parameter so the future --no-workspace-macros flag wires in one line.
- Allowlist precedence is session > agent > role > global, first Some
wins, no merging; None falls through, an empty list is an explicit
zero, all-None enables everything (mirrors SkillPolicy).
- Per-macro states per plan §6: enabled, disabled (runtime, global-level
exclusions only), locked (role/agent/session exclusions, recording the
owning level), missing (unknown allowlist names warn instead of
bailing — deliberate divergence from skills), shadowed (built-in name
collisions), and invalid (parse failures and the reserved names
enable/disable). Invalid beats allowlist exclusion beats shadowing.
- Adds enabled_macros() accessors on Role, Session, and Agent alongside
their enabled_skills() counterparts, plus paths::workspace_macros_dir.
- 37 tests: state matrix, pairwise precedence, explicit-zero pinned at
every level, workspace shadowing, reserved names, builtin collisions,
missing rows, invalid YAML, and env-gated discovery (#[serial]).
Mirrors the enabled_skills plumbing per plans/custom-commands-design.md §5:
- global: Config + AppConfig structs, from_config copy, and the
COYOTE_ENABLED_MACROS env-override arm (csv_to_vec parsing)
- role: frontmatter via parse_string_or_array (list or csv string),
plus the export() mirror so Role::save round-trips the field
- agent (non-graph): plain serde on AgentConfig; graph.yaml silently
ignores the key (pinned by test, no field on Graph by design)
- session: plain serde with csv-or-vec deserializer
Empty list/string deserializes to Some([]) (explicit zero), distinct
from absent/null (None) — pinned by tests at every level, including
the env arm (serial-fenced against the from_config tests, which read
the process env via load_envs).