--- url: 'https://hyprpilot.kilic.dev/config.md' --- # {{ $frontmatter.title }} Everything hyprpilot does is driven by one layered config: which vendors it can launch, which session profiles exist, which MCP servers and skills a launch carries, and how the launch chrome behaves. This section is the whole configuration surface — one page per root section, each with the narrative and the field-by-field reference together. ## Formats Hyprpilot reads **YAML, TOML, or JSON** — the same shape in any of them. The docs write **YAML** throughout (the recommended format, and the most readable for nested profiles); drop a `config.toml` or `config.json` instead if you prefer, the field names are identical. The global config lives at `~/.config/hyprpilot/config.{toml,json,yaml,yml}`, searched across the four extensions in priority order: ```txt .toml → .json → .yaml → .yml ``` If two files with different extensions coexist at the same layer (say `config.toml` **and** `config.yaml`), hyprpilot errors at load rather than silently picking one. `--config ` infers the format from the extension. ## A complete example ```yaml # ~/.config/hyprpilot/config.yaml profile: default: engineer profiles: - id: engineer agent: claude-code model: claude-sonnet-4-5 cwd: ~/code/my-project system_prompt: - file: ~/.config/hyprpilot/prompts/engineer.md mcps: - file: ~/.claude.json patches: - system_prompt: - file: ~/.config/hyprpilot/prompts/base.md multiplexer: set_title: true logging: level: info ``` ## Root sections | Section | Shape | Purpose | | -------------- | ------------------- | --------------------------------------------------------------------------- | | `agents` | list, keyed by `id` | Vendor CLI registry. See [Agents](./agents). | | `profile` | singleton | Picks the default session profile. See [Profiles](./profiles). | | `profiles` | list, keyed by `id` | Session presets — at least one is required. See [Profiles](./profiles). | | `mcp` / `mcps` | block / list | Skills channel + MCP catalogue. See [MCP](./mcp). | | `patches` | list | Partial-profile overlays, additive across layers. See [Patches](./patches). | | `multiplexer` | singleton | tmux/zellij title rename. See [Multiplexer](./multiplexer). | | `logging` | singleton | Tracing filter level. See [Logging](./logging). | There is **no** root-level `system_prompt` / `mcps` / `mcp` / `cwd` field — those are per-profile, or shared via [`patches`](./patches). ## Layers Config resolves in layers — compiled defaults → global config → named config-layer profile → `patches` / `--with-config` — with later layers overriding earlier ones per field (and `patches` **accumulating** across layers). [Layering](./layering) covers discovery, the merge rules, and validation. Defaults quoted in the reference tables come from the compiled `src/config/defaults.toml`, the single source of truth (the binary embeds TOML internally — your own config can be any of the three formats): ::: details The compiled defaults, verbatim ```toml # `[logging]` is intentionally NOT seeded. Leaving `logging.level` # unset lets the code fallback in `logging::init` — `error` — own the # default, keeping a fresh run quiet (errors only) unless a level is # explicitly requested via `--log-level`, `RUST_LOG`, or `[logging] # level`. Seeding a level here would re-nullify the scoped `[logging] # level` filter (K-750), so the CODE fallback owns the default. Set # `[logging] level` in your own config to raise verbosity. # `[multiplexer]` — best-effort tmux/zellij window/tab rename right # before `exec()`-ing into the vendor CLI. Titles as # `hyprpilot@` so the captain can tell agent # panes apart. No-op outside tmux/zellij regardless of this flag. [multiplexer] set_title = true # `[[patches]]` — root-level profile patches. Each patch is a # partial `ProfileConfig` shape that gets folded onto whichever # profile the captain picked. An optional `$match.profile` glob # filters which profiles a patch applies to — unset means "applies # to every profile". Same strategic-merge engine the `--with-config` # flag uses; `$patch: replace` / `$deleteFromPrimitiveList/` # directives compose. # # **Additive across config layers**: a user config layer's # `[[patches]]` EXTENDS this list rather than replacing it, so the # seed below always survives. Captains add more `[[patches]]` entries # (later wins on field collision) or override the `mcp` field # per-profile; to wipe an earlier patch's field, fold a later patch # with `$patch: replace` inside the field body. # # Default seed: one unscoped patch carrying the skills directory, the # name each in-tree server is injected under, and the harness's # tunable ceilings. # # `[mcp] enabled`, `autoAcceptTools` and `autoRejectTools` are NOT here: # their accessors `.expect()` a value, so they must come from the typed # `McpConfig::default()` the resolver backfills per-leaf, which a # programmatic `Config` carrying no patches also gets. The # `[mcp.harness]` block below is not backfilled that way (it is nested, # and the typed default leaves it `None`), so its numbers live here — # the file a captain actually edits — with the Rust fallbacks in # `config/mcp.rs` covering only the no-patches case. A paired test pins # the two equal, so they cannot drift apart. [[patches]] [patches.mcp] # `maxDepth` — how many levels of harness delegation are allowed. `1` # means you may spawn delegates and they may not. Read in one place: the # `[mcp.harness]` block a gate is deciding on. It answers both "does a # session at depth d get a harness injected" and "may a sidecar at depth # d spawn" with the same `d < maxDepth`. `0` denies both everywhere. # # Raising it is a resource decision, not a security one — a session # ceiling bounds one sidecar's own table, so N delegates each running N # sessions fans out past anything the lead can see. # # `maxSessions` — finished sessions retained per sidecar before the # oldest are evicted with their transcripts. A running session is never # evicted and never counts against this. `0` retains everything. # # `maxLiveSessions` — sessions allowed to RUN at once before `spawn` is # refused. `0`, the default, allows any number: how many agents are # worth running at once belongs to your machine and your work, not to # this file. Set it on a shared or resource-tight host. # # Written snake_case to match the rest of this file. Both spellings # parse, but patches merge by KEY STRING before anything is typed, so a # key seeded here must be OVERRIDDEN in the same spelling — `maxDepth` # in your config against `max_depth` here arrives at serde as two keys # and fails config load with `duplicate field`. [patches.mcp.harness] name = "hyprpilot-harness" max_depth = 1 max_sessions = 64 max_live_sessions = 0 notify_on_complete = true # `name` is the key the vendor's MCP catalog is written under, so it is # also the tool prefix the agent sees (`mcp__hyprpilot-skills__read_skill`) # and the name RESERVED against a same-named configured server. The # injector reads this field and nothing else, so renaming a server is a # config edit rather than a rebuild. The Rust constants in # `config/mcp.rs` only cover a `Config` carrying no patches; a test pins # them equal to these. [patches.mcp.serve] name = "hyprpilot" [patches.mcp.skills] name = "hyprpilot-skills" [[patches.mcp.skills.dirs]] dir = "~/.config/hyprpilot/skills" # `[[agents]]` — vendor registry. `command` / `args` are the NATIVE # vendor CLI the launcher `exec`s into directly (no ACP bridge). The # launcher projects the resolved profile's model / effort / mode / # system-prompt / MCP catalog onto each vendor's native flags at # spawn time; bare `args = []` launches the vendor's interactive TUI. [[agents]] id = "claude-code" provider = "claude-code" command = "claude" args = [] [[agents]] id = "codex" provider = "codex" command = "codex" args = [] [[agents]] id = "opencode" provider = "opencode" command = "opencode" args = [] # `[[profiles]]` — captain-supplied. NOT seeded by defaults so a # fresh install presents a single source of truth for "which profiles # exist": the captain's on-disk config. Validation rejects an empty # `[[profiles]]` list at config-load, so fresh installs without any # profile won't launch — the captain configures at least one before # spawning. `[profile] default` picks which profile runs when the # positional `[PROFILE]` argument isn't passed; required when more than # one profile exists. ``` ::: ## Validation Every section validates types and rejects unknown fields at load — typos fail fast with an error naming the offending field path. Cross-field references are checked too: `profiles[].agent` must reference a real `agents[].id`, and `profile.default` must name a real `profiles[].id`. The `profiles` list must be non-empty — a fresh install with no profile refuses to launch rather than guessing. --- --- url: 'https://hyprpilot.kilic.dev/config/layering.md' --- # {{ $frontmatter.title }} Hyprpilot reads layered config. Each source overrides the one before it for the fields it sets, so you only write what you want to change. ## The layers 1. **Compiled defaults** — every knob has a working default, baked into the binary from `src/config/defaults.toml`. 2. **Global config** — `~/.config/hyprpilot/config.{toml,json,yaml,yml}`, or an explicit `--config `. 3. **Named config-layer profile** — `~/.config/hyprpilot/profiles/.{ext}`, picked with `--config-profile ` or `HYPRPILOT_CONFIG_PROFILE=`. 4. **`patches` and `--with-config`** — profile overlays applied at resolve time. See [Patches](./patches) and [Ad-hoc Overlays](../runtime/with-config). Any layer can be any supported format — a YAML global config composes with a TOML config-layer profile. See [Formats](./#formats) for extension discovery. ## Merge rules * **Scalar fields overwrite** — a later layer's value wins for the fields it sets. * **The keyed `agents` / `profiles` lists merge by `id`** — a later layer's entry with a matching `id` replaces the earlier entry wholesale, and new ids append. There is no field-level merge inside a single entry. * **The `patches` list is additive** — each layer's patches **append** to the earlier layers' list instead of replacing it, so the seeded default patch and your global patches survive a config-layer profile that adds more. See [Patches → Additive across layers](./patches#additive-across-layers) for how to neutralize an inherited patch. `~` and `${VAR}` / `${env:VAR}` in path-valued fields expand at consume time; relative paths resolve against the current directory. ## Config-layer profile ≠ session profile The word "profile" lives in two parallel namespaces — keep them apart: | Concept | Addressed via | Purpose | | -------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | Config-layer profile | `--config-profile` / `HYPRPILOT_CONFIG_PROFILE` | Layer a different config file overlay (e.g. `work` vs `personal`). | | Session profile | `profiles` in config, picked via the positional `[PROFILE]` | Which agent + model + cwd + system prompt + MCPs a launch uses. See [Profiles](./profiles). | A config-layer profile can itself define or override session profiles — that is the point: `HYPRPILOT_CONFIG_PROFILE=work` can swap your whole `profiles` registry. ## Validation After the layers merge, the whole config is validated in one pass: * **Unknown fields reject at parse time** — every section is a closed shape, so a typo like `modle: …` fails with an error naming the field. * **Closed sets are enums** — `provider`, `--log-level` values, and config formats reject unknown values at parse, not deep in a launch. * **Cross-field references are checked** — `profiles[].agent` must reference a real `agents[].id`, and `profile.default` must name a real `profiles[].id`. * **The `profiles` list must be non-empty** — a fresh install with no profile refuses to launch rather than guessing. Validation failures abort startup with a readable field-path error, so a broken config never reaches the vendor CLI. ## Where things live | Path | What | | ------------------------------------------------- | --------------------------------------- | | `~/.config/hyprpilot/config.{toml,json,yaml,yml}` | Global config. | | `~/.config/hyprpilot/profiles/*.{ext}` | Named config-layer overlays. | | `~/.config/hyprpilot/skills//SKILL.md` | Skill bundles (default catalogue root). | | `~/.config/hyprpilot/mcps/*.json` | MCP catalogue files (your convention). | --- --- url: 'https://hyprpilot.kilic.dev/config/agents.md' --- # {{ $frontmatter.title }} An agent is the vendor CLI hyprpilot launches: `claude`, `codex`, or `opencode`. An `agents` entry declares the native binary to `exec()` and which vendor projection to apply; profiles reference agents by id. ## Registering an agent The compiled defaults already seed the three built-ins — `claude-code`, `codex`, and `opencode`, each with `args: []` — so most captains never write an `agents` entry at all. You only add one to point at a different binary, pin a default model, or set agent-wide env: ```yaml agents: - id: claude-code # how profiles reference it provider: claude-code # closed provider enum command: claude # the NATIVE binary hyprpilot execs args: [] # bare → the vendor's interactive TUI model: claude-sonnet-4-5 # optional default model env: ANTHROPIC_API_KEY: ${env:ANTHROPIC_API_KEY} ``` `${env:VAR}` / `${VAR}` and `~` in path- and env-valued fields expand at launch time from your shell environment. ## Fields | Field | Type | Default | What it does | | ---------- | ----------------- | ------- | ------------------------------------------------------------------------------------ | | `id` | string | — | How profiles reference this agent. Unique within `agents`. | | `provider` | enum | — | Which vendor projection to apply: `claude-code`, `codex`, or `opencode`. Closed set. | | `command` | string | — | The native CLI binary hyprpilot `exec()`s. Mandatory. | | `args` | string\[] | `[]` | Base arguments. `[]` launches the vendor's interactive TUI. | | `model` | string (optional) | unset | Default model; a profile's `model` overrides it (profile > agent > vendor default). | | `effort` | string (optional) | unset | Default reasoning-effort knob, mapped to the vendor where supported. | | `cwd` | path (optional) | unset | Default working directory; a profile `cwd` and `--cwd` override it. | | `env` | map (optional) | `{}` | Environment overlaid on the inherited shell env. | ## Seeded entries The compiled defaults seed three entries — override one by redeclaring its `id` (whole-entry replace, no field-level merge): | `id` | `provider` | `command` | `args` | | ------------- | ------------- | ---------- | ------ | | `claude-code` | `claude-code` | `claude` | `[]` | | `codex` | `codex` | `codex` | `[]` | | `opencode` | `opencode` | `opencode` | `[]` | There is no `agent` singleton and no generic/custom provider variant — see [the provider enum](#the-provider-enum) for how to launch wrapper binaries anyway. ## The provider enum `provider` is a closed set — every agent must be one of the three variants so that every profile gets the full native projection: | Provider | Vendor | Default `command` | | ------------- | ------------------------------- | ----------------- | | `claude-code` | Anthropic Claude Code | `claude` | | `codex` | OpenAI Codex | `codex` | | `opencode` | [opencode](https://opencode.ai) | `opencode` | There is no generic escape-hatch provider. If you want to launch a wrapper or a hand-rolled CLI, declare its `command` / `args` under one of these providers (accepting that vendor's flag conventions), or swap the binary per-profile via the flat [`command`/`args`/`env` override](./profiles#the-flat-command-args-env-override). ## Native-flag projection Each provider variant maps to a per-vendor command builder that projects the resolved profile — model, effort, mode, system prompt, MCP catalogue, tool policy — onto that vendor's flags and environment: * **`claude-code`** — `--model`, `--effort`, `--permission-mode` (from `mode`), `--append-system-prompt`, MCP servers as `--mcp-config ` pointing at a per-launch 0600 temp file (keeps expanded header secrets out of the world-readable argv — see [MCP → Secrets](./mcp#secrets-in-the-vendor-handoff)), and tool policy as `--allowedTools` / `--disallowedTools` (`mcp__server__tool` naming). * **`codex`** — `--model`, effort as a `-c model_reasoning_effort=…` override, MCP servers as `-c mcp_servers..*` config keys, and tool policy as exact-name `enabled_tools` / `disabled_tools` / per-tool `approval_mode`. Codex does not support wildcard tool patterns in those fields, so wildcard patterns are skipped for Codex with a warning. * **`opencode`** — `--model`, `mode` as the opencode `--agent` name (a synthetic `hyprpilot` agent when unset), config (system prompt, effort variant, MCP servers) via `OPENCODE_CONFIG_CONTENT`, and tool policy as ordered `OPENCODE_PERMISSION` rules (`server_tool` naming, wildcards supported). MCP transport (stdio / http / sse) is inferred from field presence (`command` → stdio, `url` → http/sse). Any provider-native argument you pass after `--` suppresses the generated equivalent, so you can always override hyprpilot's projection by hand. ## Modes `mode` on a profile (or `--mode` on the CLI) is a free string projected onto each vendor's native mode surface: * **claude-code** — passed to `--permission-mode` (e.g. `plan`, `default`). * **codex** — Codex has no single mode flag. The value must be either an approval policy (`untrusted`, `on-request`, `never`, or the deprecated `on-failure`) mapped to `--ask-for-approval`, or a sandbox mode (`read-only`, `workspace-write`, `danger-full-access`) mapped to `--sandbox`. An unsupported value fails before the terminal is handed to `codex`. * **opencode** — used as the `--agent` name. ## Swapping the agent per launch The profile is the single source of truth for which agent it runs — there is no `--agent` launch flag. To run an existing profile against a different vendor for one launch, overlay the `agent` field with [`--with-config`](../runtime/with-config): ```sh hyprpilot engineer --with-config '@{"agent":"codex"}' ``` The overlay wins over whatever agent the (patched) profile names — the profile's own `model` / `mode` / prompt overlays still apply, projected through the new agent's provider. To make the swap permanent, add a dedicated `profiles` entry instead. --- --- url: 'https://hyprpilot.kilic.dev/config/profiles.md' --- # {{ $frontmatter.title }} A profile is a preset that binds together everything a launch needs: which agent vendor, which model, where it runs, what system prompt it loads, which MCPs it has access to, what mode it starts in. Pick a profile — from the interactive picker or the positional `[PROFILE]` argument — and hyprpilot resolves it, projects it onto the vendor's native flags, and `exec()`s. This is the most important config you'll write. Everything else tunes the launch chrome — profiles tune the work. ## Anatomy ```yaml profiles: - id: engineer # picker label, also `hyprpilot engineer` agent: claude-code # must match an agents id model: claude-sonnet-4-5 # optional; overrides the agent's default cwd: ~/code/hyprpilot # optional; falls back to $PWD mode: default # optional; vendor-specific (e.g. plan / default) system_prompt: - file: ~/.config/hyprpilot/prompts/base.md - file: ~/.config/hyprpilot/prompts/engineer.md mcps: - file: ~/.config/hyprpilot/mcps/team.json - file: ~/.claude.json ``` Launch it: ```sh hyprpilot profiles # list configured profiles hyprpilot engineer # resolve + exec directly (positional) hyprpilot # pick a profile interactively, then exec ``` ## Picking the default ```yaml profile: default: engineer # which profiles entry bare `hyprpilot` picks ``` Resolution at launch time: 1. The positional `[PROFILE]` id wins. 2. Otherwise `profile.default`. 3. Otherwise — if you didn't pass a profile and no default is set — the interactive picker opens with `profile.default` pre-selected under the cursor. If neither a picked nor a default profile resolves to a real `profiles` entry, the launch errors. There is no bare-agent fallback. The `profiles` list must be **non-empty** — the compiled defaults seed zero profiles, and validation rejects an empty list at load. ## Fields ### `profile` | Field | Type | Default | What it does | | --------- | ----------------- | ------- | ------------------------------------------------------------------------------------------------------------ | | `default` | string (optional) | unset | The `profiles[].id` bare `hyprpilot` launches when no positional profile is given. Must name a real profile. | ### `profiles` entries | Field | Type | Default | What it does | | --------------- | -------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------- | | `id` | string | — | Unique within `profiles`. The picker row + the positional `hyprpilot `. | | `agent` | string | — | Which `agents` entry to launch. Must reference a real `agents[].id`. | | `model` | string (optional) | unset | Overrides the agent's default model. Precedence: profile > agent > vendor default. | | `effort` | string (optional) | unset | Reasoning-effort knob, mapped to the vendor's config surface where supported. | | `cwd` | path (optional) | unset | Where the agent runs. `~`, `${VAR}` expansion supported; falls back to the agent `cwd`, then `$PWD`. | | `mode` | string (optional) | unset | Vendor-specific starting mode. See [Agents → Modes](./agents#modes). | | `headless` | bool (optional) | `false` | Force a non-interactive one-shot launch (requires a piped prompt). See [Headless](#headless). | | `system_prompt` | `{ file, inject? }[]` (optional) | unset | Prompt files read at resolve time and prepended to the first turn. `[]` = no prompt. `inject` defaults `true`. | | `mcps` | `{ file … }[]` (optional) | unset | Per-profile MCP catalogue — wholesale-replaces the shared set. `[]` = no MCPs. See [MCP](./mcp). | | `mcp` | `mcp` block (optional) | unset | Per-profile override of the in-tree MCP / skills block — wholesale-replaces the global. | | `command` | string (optional) | unset | Replaces the base agent's `command` wholesale for this profile. | | `args` | string\[] (optional) | unset | Replaces the base agent's `args` wholesale for this profile. | | `env` | map (optional) | `{}` | Overlays the base agent's `env` per key; the profile's key wins on collision. | | `harness` | `{ enabled? }` (optional) | unset | Per-profile [agent-harness](../runtime/harness) policy. **Opt-in** — without this block the harness cannot drive the profile. | ## What a profile overrides At resolve time the profile's `model` / `effort` / `mode` / `cwd` override the agent entry — the profile is the more specific scope. Model precedence, for example, is **profile > agent > vendor default**. The `--mode` / `--cwd` flags then override the resolved profile per launch; for a one-off `model` (or any other profile field) use [`--with-config`](../runtime/with-config) — there is no `--model` flag. ## The flat `command` / `args` / `env` override Sometimes a profile needs to launch a *different* binary or extra flags than the base agent entry declares — a canary build, a wrapper script, a long flag list. Instead of a nested override block, a profile carries three flat top-level fields: ```yaml profiles: - id: engineer-canary agent: claude-code command: claude-canary # REPLACES the agent's command args: # REPLACES the agent's args - --dangerously-skip-permissions env: ANTHROPIC_LOG: debug # OVERLAYS the agent's env per-key ``` * **`command`** — when set, replaces the base agent's `command` wholesale for this profile. * **`args`** — when set, replaces the base agent's `args` wholesale. Flags have no stable key to merge by (`--flag value`, `-c k=v`, positionals), so this is a swap, not an append: to add one flag to an otherwise long agent-args list, restate the full list here. * **`env`** — overlays onto the base agent's `env` per key. The profile's key wins on collision; keys the profile doesn't mention are left untouched. The agent's `provider` still drives the native-flag projection (model, mode, MCP config); the flat override only swaps what binary is launched with what arguments and environment. ## System prompts `system_prompt` is an array of `{ file, inject? }` entries. Each file is read at **resolve** time (not ahead of time), the surviving bodies are concatenated with blank-line separators, and the result is prepended to your first turn so the agent reads it as context before your message. ```yaml system_prompt: - file: ~/.config/hyprpilot/prompts/base.md # shared persona - file: ~/.config/hyprpilot/prompts/engineer.md # per-profile addendum ``` Composition lets a base persona + per-profile addendum land without juggling templates. `system_prompt: []` is the explicit "no prompt" off-switch. Because files are read at resolve time, a missing file fails loudly on the next launch rather than silently. ### Per-entry inject toggle Each entry takes an optional `inject` boolean (default `true`). Set it `false` to keep a file listed — for reference, or to stage it for later — without its body actually being injected: ```yaml system_prompt: - file: ~/.config/hyprpilot/prompts/base.md - file: ~/.config/hyprpilot/prompts/notes.md inject: false # skipped ``` | Field | Type | Default | What it does | | -------- | --------------- | ------- | --------------------------------------------------------------------- | | `file` | path | — | Prompt file, read at resolve time — a missing file fails the launch. | | `inject` | bool (optional) | `true` | Whether this entry's body rides the launch's system-prompt injection. | ## Putting a profile on the harness `mcp harness` lets a connected agent launch your profiles. **It is opt-in per profile** — declaring a `harness` block is what makes one available: ```yaml profiles: - id: personal/engineer agent: claude-code harness: enabled: true # an agent may drive this one - id: personal/deploy agent: claude-code # no block — the harness cannot touch it ``` A profile without the block, or with `enabled: false`, disappears from `list_profiles` **and** is refused by `spawn` / `session_send` by id. Both halves matter: `spawn` dispatches on whatever id it is handed, so hiding a profile from the listing alone would leave it reachable by anyone who already knew the name. Default-deny because `spawn` runs a profile's `command` as you — the set an agent may drive should be a list you wrote, not everything that happens to be configured. To opt a whole family in at once, use a `$match`ed [patch](./patches) instead of repeating the block: ```yaml patches: - $match: profile: 'personal/*' harness: enabled: true ``` This is a *harness* policy, not a hidden flag — `hyprpilot profiles` still lists every profile and `hyprpilot personal/deploy` still launches it. It says "an agent may not drive this one", not "nobody may". It is a block rather than a bare `harness: true` so later per-profile harness policy lands as a sibling field instead of a second top-level flag. ## Headless `headless: true` forces the profile to launch **non-interactively** — hyprpilot buffers stdin as the prompt and projects the vendor's one-shot invocation (`claude --print` / `codex exec` / `opencode run`), then the vendor exits: ```yaml profiles: - id: commit-msg agent: claude-code headless: true system_prompt: - file: ~/.config/hyprpilot/prompts/commit.md ``` ```sh git diff --staged | hyprpilot commit-msg ``` The flag defaults `false` (interactive TUI). Note that a piped stdin **auto-triggers** headless regardless of this flag — `headless: true` is only needed when you want a profile to *refuse* an interactive launch. When headless is active but stdin is an interactive TTY (no prompt to read), the launch errors instead of opening a picker it can't drive. Full details, per-vendor projection, and the `-- …` escape hatch live in [Runtime → Launching → Headless](../runtime/launch#headless-stdin-pass-through). ## MCPs and skills MCP servers extend an agent with tools; skills attach markdown context. See [MCP](./mcp) and [Runtime → Skills](../runtime/skills). In short: * `mcps` on a profile is a per-profile MCP catalogue that wholesale-replaces the shared set; `mcps: []` means "no MCPs". * The in-tree `hyprpilot` MCP server (which delivers your skills) is configured under the `mcp` block, seeded globally via [`patches`](./patches) and overridable per-profile. ## Examples ### A planning profile with no MCPs ```yaml profiles: - id: plan agent: claude-code model: claude-opus-4-5 mode: plan mcps: [] ``` ### A code-review profile pinned to a repo ```yaml profiles: - id: review-hyprpilot agent: claude-code cwd: ~/code/hyprpilot system_prompt: - file: ~/.config/hyprpilot/prompts/reviewer.md ``` ### A profile launching a wrapper binary ```yaml profiles: - id: sandboxed agent: claude-code command: firejail args: - --net=none - claude ``` --- --- url: 'https://hyprpilot.kilic.dev/config/patches.md' --- # {{ $frontmatter.title }} If you want the same knob on several profiles — a shared system prompt, a team MCP file — don't repeat it per profile: put it in a root-level `patches` entry. A patch is a partial profile shape that merges onto whichever profile gets picked at resolve time. This is the single mechanism for **profile-shared knobs** — there is deliberately no root-level `system_prompt` / `mcps` / `mcp` field. ## Writing a patch ```yaml patches: # Unscoped — applies to every profile. - system_prompt: - file: ~/.config/hyprpilot/prompts/base.md # Scoped — only profiles whose id matches the glob. - $match: profile: work/* mcps: - file: ~/.config/hyprpilot/mcps/work.json ``` Anything you can write under a `profiles` entry you can write in a patch — the patch body is the same partial-profile shape. ## Shape Each `patches` entry is a partial profile shape — any profile field is valid — plus one optional control sibling: | Field | Type | Default | What it does | | -------- | ----------------- | ------- | ----------------------------------------------------------------------- | | `$match` | object (optional) | unset | Filters which profiles the patch applies to; stripped before the merge. | | *(rest)* | partial profile | — | Fields folded onto the picked profile with the strategic-merge engine. | ### `$match` | Field | Type | Default | What it does | | --------- | --------------- | ------- | --------------------------------------------------------------------------------------------- | | `profile` | glob (optional) | unset | Profile-id glob (crosses `/`, so `work/*` matches `work/claude/opus`). Unset = every profile. | Patches fold left-to-right in declaration order; a later patch wins on field collision. ## Additive across layers `patches` accumulates across [config layers](./layering) instead of overwriting: the compiled defaults' patches come first, then your global config's, then the named config-layer profile's — each layer **appends** to the list, and the whole accumulated list folds onto the picked profile in that order. That means a config-layer profile can add a work-only MCP patch without wiping the seeded default patch or your global ones. It also means you cannot delete an earlier layer's patch by redeclaring the list — to neutralize an inherited patch, add a later patch that overrides the same fields, using the [merge directives](#merge-semantics) to wipe rather than merge: ```yaml # In a later layer: undo an inherited patch's extra prompts for `scratch`. patches: - $match: profile: scratch system_prompt: - $patch: replace # sentinel: replace the whole array with the rest (nothing) ``` ## Merge semantics Patches fold with a strategic-merge engine — the same one [`--with-config`](../runtime/with-config) uses: | Directive | Where | Effect | | ---------------------------------- | ---------------- | ------------------------------------------------------------------------------------------- | | *(none)* | objects | Recursive field merge; scalar leaves overwrite (later wins). | | *(none)* | keyed arrays | Merge by `id`; new ids append. | | *(none)* | primitive arrays | Append + de-duplicate. | | `$patch: replace` (object key) | objects / maps | `field: { $patch: replace, … }` drops the base value and takes the patch's. | | `- $patch: replace` (sentinel) | arrays | A `{ $patch: replace }` first element replaces the whole array with the remaining elements. | | `$patch: delete` (keyed entry) | keyed arrays | `{ id: …, $patch: delete }` removes the entry with that `id`. | | `$deleteFromPrimitiveList/` | primitive arrays | Remove the listed entries from the base array. | `$patch: replace` also works on the profile side — a profile can shield a field from patch overlays: ```yaml profiles: - id: clean agent: claude-code env: $patch: replace # ignore any env a patch would overlay ``` ## The default patch The compiled defaults seed one unscoped patch that points the in-tree `hyprpilot` MCP server at the XDG skills directory — this is why [skills](../runtime/skills) work out of the box: ```yaml patches: - mcp: skills: - dir: ~/.config/hyprpilot/skills ``` The seed carries **only** the skills root. The other `mcp` knobs — `enabled: true`, `autoAcceptTools: ['*']`, `autoRejectTools: []` — are the built-in defaults, backfilled per leaf at resolve time rather than spelled out in the seed. Because patches accumulate, your own `patches` entries land **after** this seed (later wins on field collision) — or override the `mcp` field per-profile. ## Where patches sit in resolution Every launch resolves the effective profile through one path: 1. Pick the base profile — the positional `[PROFILE]` id first, then `profile.default`. Errors when neither names a real `profiles` entry. 2. Fold each accumulated `patches` entry (filtered by its `$match.profile` glob) in declaration order. 3. Fold each [`--with-config`](../runtime/with-config) overlay in declaration order. 4. Deserialize the merged result back into a profile and re-validate. The resolved profile is the single source of truth for its agent and model — there is no per-launch `--agent` / `--model` override. To swap either for one launch, add an [`--with-config`](../runtime/with-config) overlay (e.g. `--with-config '@{"agent":"codex"}'`). --- --- url: 'https://hyprpilot.kilic.dev/config/mcp.md' --- # {{ $frontmatter.title }} Two related surfaces share this page: the **`mcps` catalogue** declares the external Model Context Protocol servers an agent can call (plus a per-server tool policy), and the **`mcp` block** configures hyprpilot's three in-tree servers. At launch, hyprpilot merges the catalogue and projects it onto the vendor's native MCP surface — you keep one catalogue, every vendor reads it. ## The `mcps` catalogue Each `mcps` entry carries **either** a `file` path **or** an inline `mcp_servers` map (exactly one — declaring both, or neither, is a load error). File paths follow the standard `{ "mcpServers": { … } }` shape that Claude Code, Codex, and Cursor all read, so you can drop your existing `~/.claude.json` straight in: ```yaml profiles: - id: engineer agent: claude-code mcps: - file: ~/.claude.json - file: ~/.config/hyprpilot/mcps/team.json ignore: - scratch-* - '*-internal' ``` Inside each file: ```json { "mcpServers": { "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] }, "github": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${env:GITHUB_TOKEN}" } } } } ``` Files iterate in order; a later file's server overrides an earlier one of the same name. A single malformed file warns and is skipped rather than aborting the launch. Transport is inferred by field presence (`command` → stdio, `url` → http/sse). Everything except the typed `hyprpilot` policy block stays opaque, so vendor-specific server fields pass through untouched. ### Fields | Field | Type | Default | What it does | | ------------- | ---------------- | ------- | ------------------------------------------------------------------------------ | | `file` | path | — | An `{ "mcpServers": { … } }` JSON file. Exactly one of `file` / `mcp_servers`. | | `mcp_servers` | map | — | Inline server map, same shape as the file's `mcpServers` value. | | `ignore` | string\[] (globs) | `[]` | Server names matching any pattern are dropped. | ### Inline servers If you want a one-off server without a file, declare `mcp_servers` on the entry directly: ```yaml mcps: - mcp_servers: hyprpilot-nvim: command: uvx args: - hyprpilot-nvim-mcp ``` ### Ignoring servers `ignore` is an optional glob array per entry. Server names matching any pattern are dropped before they reach the agent. Globs anchor against the full server name — `work-*` matches `work-foo` but not `pre-work-foo`. ### Per-profile override `mcps` on a profile wholesale-replaces the shared catalogue for that profile. `mcps: []` means "no MCPs at all" — handy for a sandboxed read-only profile. To share one catalogue across every profile, put it in a [`patches`](./patches) entry instead of repeating it. ::: info Reserved names Each in-tree server's resolved name is reserved — by default `hyprpilot`, `hyprpilot-skills`, and `hyprpilot-harness` (see [the `mcp` block](#the-mcp-block)). A configured server of that name is replaced by the injected entry, and renaming a server via `mcp..name` moves which name is reserved. ::: ## Tool policy Each server entry takes an optional `hyprpilot` block for tool visibility and approval policy: ```json { "mcpServers": { "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], "hyprpilot": { "includeTools": ["read_*", "list_*"], "excludeTools": ["delete_*"], "autoAcceptTools": ["read_*"], "autoRejectTools": ["delete_*"] } } } } ``` | Field | Type | Default | What it does | | ----------------- | ---------------- | --------- | -------------------------------------------------------------- | | `includeTools` | string\[] (globs) | unset | Visibility allow-list. Unset = no allow-list; `[]` = deny all. | | `excludeTools` | string\[] (globs) | `[]` | Visibility deny-list. Exclude beats include. | | `autoAcceptTools` | string\[] (globs) | inherited | Approval accept list. Falls back to `mcp.autoAcceptTools`. | | `autoRejectTools` | string\[] (globs) | inherited | Approval reject list. Reject beats accept. | * Globs are **server-relative** — write `read_*`, not `mcp__filesystem__read_*`; the `mcp____` prefix is implicit. * `includeTools` / `excludeTools` control **visibility**; `autoAcceptTools` / `autoRejectTools` control **approval**. * Servers with no per-server override inherit the `mcp` block's `autoAcceptTools` (default `['*']`) / `autoRejectTools`. * Every other key on a server definition passes through to the vendor untouched. ## The `mcp` block hyprpilot ships **three** in-tree MCP servers. Each is its own subcommand, its own process, and its own catalogue entry, so each can be enabled, renamed, and given a tool policy independently: | Server | Subcommand | Default name | Serves | Default | | ------------- | ----------------------- | ------------------- | ------------------------------------------------------------------------------------------- | ---------- | | General tools | `hyprpilot mcp serve` | `hyprpilot` | `open` | enabled | | Skills | `hyprpilot mcp skills` | `hyprpilot-skills` | `list_skills` / `read_skill` / `list_skill_references` / `read_skill_references` / `reload` | enabled | | Agent harness | `hyprpilot mcp harness` | `hyprpilot-harness` | `list_profiles` / `spawn` / `session_*` | *disabled* | The `mcp` block gates and configures all three: ```yaml mcp: enabled: true # master gate over every in-tree server autoAcceptTools: - '*' autoRejectTools: [] serve: enabled: true skills: dirs: - dir: ~/.config/hyprpilot/skills - dir: ~/.team/shared-skills ignore: - work-* - '*-experimental' harness: enabled: true max_sessions: 64 max_live_sessions: 0 ``` | Field | Type | Default | What it does | | ----------------- | ---------------- | ------- | ---------------------------------------------------------------------------------- | | `enabled` | bool | `true` | Master gate. `false` auto-injects **nothing**, whatever the per-server blocks say. | | `serve` | object | — | The general-tools server. See below. | | `skills` | object | — | The skills server. See below. | | `harness` | object | — | The agent-harness server. See below. | | `autoAcceptTools` | string\[] (globs) | `['*']` | Default tool-approval accept list, copied onto servers with no per-server policy. | | `autoRejectTools` | string\[] (globs) | `[]` | Default tool-approval reject list. Reject beats accept. | A profile's `mcp` field wholesale-replaces this block. `autoAcceptTools` / `autoRejectTools` are glob-validated at config load (like the `ignore` lists) — a malformed glob errors at startup with a field-path message instead of silently failing at match time. Every per-server block accepts `enabled`, `name`, `autoAcceptTools`, and `autoRejectTools`. The default names in the table above are not compiled in — they are seeded as `mcp.serve.name` / `mcp.skills.name` / `mcp.harness.name` in the shipped `[[patches]]`, and the injector reads that field and nothing else, so a rename is a config edit. `name` is what the vendor prefixes tool calls with, so renaming the skills server to `docs` turns `mcp__hyprpilot-skills__read_skill` into `mcp__docs__read_skill` — anything that addresses a tool by name (a skill file, a system prompt) has to follow. The `hyprpilot://` resource URIs are a fixed scheme and never change. A per-server `autoAcceptTools` overrides the block-level default rather than merging with it. ### `mcp.serve` The general-tools server — the surface for things that are neither a skills read nor an agent launch. `open` today. Stateless, so nothing to reload or reap. ### `mcp.skills` | Field | Type | Default | What it does | | ------ | -------------------- | -------- | ------------------------------------------------------------ | | `dirs` | `{ dir, ignore? }[]` | XDG root | Skill roots — flat directories of `/SKILL.md` bundles. | Unlike the other two, this server is also gated on having something to serve: if `dirs` resolves to no skills at all, nothing is injected. The root defaults to `~/.config/hyprpilot/skills`, seeded through an unscoped [`patches`](./patches) entry rather than a compiled default, so a user layer's `patches` extends the seed instead of replacing it. #### `dirs` entries | Field | Type | Default | What it does | | -------- | ---------------- | ------- | -------------------------------------------------------------------------- | | `dir` | path | — | Skill root to scan. Missing roots warn and are skipped. | | `ignore` | string\[] (globs) | `[]` | Slugs matching any pattern are skipped. First root wins on slug collision. | ### `mcp.harness` | Field | Type | Default | What it does | | -------------------- | ---------------- | ------- | -------------------------------------------------------------------------------------------------------------- | | `max_depth` | int | `1` | Levels of delegation allowed. See below. | | `max_sessions` | int | `64` | **Finished** sessions retained before the oldest are evicted. `0` retains every one. See below. | | `max_live_sessions` | int | `0` | Sessions allowed to run at once before `spawn` is refused. `0` allows any number. See below. | | `notify_on_complete` | bool | `true` | Push a completion event into the lead's context when a turn finishes. See [Agent Harness](../runtime/harness). | | `includeProfiles` | string\[] (globs) | unset | Profile ids **this launch** may delegate to. Unset applies no filter; `[]` means none. See below. | | `excludeProfiles` | string\[] (globs) | `[]` | Profile ids this launch may **not** delegate to. Beats `includeProfiles` on overlap. | | `mcp` | `mcp` block | unset | The `[mcp]` block every delegate this harness spawns receives. See below. | `max_depth`, `max_sessions`, `max_live_sessions` and `notify_on_complete` are seeded by the compiled defaults, so your own config overrides them per field without restating the rest. ::: warning Write a seeded key the way the seed writes it Config keys accept both `snake_case` and `camelCase`, but patches merge by **key string** before anything is typed. These four are seeded `snake_case`, so overriding one as `maxDepth` / `maxSessions` / `maxLiveSessions` / `notifyOnComplete` arrives as a second key and fails config load with `duplicate field`. Every other key is unaffected — nothing seeds them, so there is nothing to collide with. ::: `includeProfiles` / `excludeProfiles` are the **launcher's** scope, distinct from the target's own `profiles.harness` opt-in. The two AND — a glob here narrows what is already nominated and can never promote a profile that never opted in. `*` crosses `/` (same `globset` semantics as `$match.profile`), so `personal/*` reaches `personal/kilic/glm-5.2`. Full treatment in [Agent Harness → Scoping delegation per launch](../runtime/harness#scoping-delegation-per-launch). ### `mcp.harness.max_depth` How many levels deep delegation may go. It is read in exactly one place — the `mcp.harness` block a gate is deciding on — and answers two questions with the same comparison, `depth < max_depth`: * whether a session running at that depth gets a harness server **injected** at all, and * whether a running sidecar at that depth may **`spawn`**. | Value | Effect | | ----- | -------------------------------------------------------------------------------- | | `1` | Default. You delegate; your delegates do not. They get no harness server at all. | | `2` | Your delegates get a harness and may delegate once more. Theirs may not. | | `0` | Nothing anywhere gets a harness injected, and nothing may spawn. | The injection half is **absolute** — no `enabled: true` re-opens it, because a harness at the cap could only ever refuse `spawn`, so injecting one buys a long-lived process and seven tools that exist to error. **Raising it is a resource decision, not a security one.** A `max_live_sessions` ceiling, where you set one at all, covers only its own sidecar's table, so an extra level lets N delegates each run N sessions — a fan-out no single ceiling catches and the lead that started the tree cannot see. There is deliberately no upper bound; the trade is yours to make. ### `mcp.harness.max_live_sessions` How many sessions this sidecar may have **running** at once. Past it, `spawn` is refused with a message naming the knob; `session_kill` on a finished or runaway session frees a slot. It bounds breadth where `max_depth` bounds recursion. **It is `0` — off — by default.** How many agents are worth running at once is a property of your machine and your work, and hyprpilot has no way to guess it, so it refuses nothing until you say otherwise. Set a number on a shared or resource-tight host, where a profile's `command` being an arbitrary binary makes an unbounded fan-out something the host pays for. Leave it at `0` where an agent fanning out wide is exactly the point. ### `mcp.harness.max_sessions` How many **finished** sessions are retained before the oldest are evicted with their transcripts. `0` retains all of them. Running sessions are outside this count, not merely spared by it — they hold a live model connection rather than history. Counting one would spend the retention budget on work still in flight, so with the concurrency ceiling off, enough concurrent agents would evict every transcript the cap exists to keep. `session_list` follows the same priority: running sessions lead, then most recent turn first. Only distinct `spawn`s grow the table — a conversation reuses its session however many turns it runs — so this bounds a long-lived sidecar's memory and temp directories without a tool you have to remember to call. ### `mcp.harness.mcp` An `mcp` block, same shape as the top-level one, applied to every delegate this harness spawns. It is folded **per key** onto whatever the delegate profile itself resolved: a key you set here wins, a key you leave unset inherits. ```yaml mcp: harness: enabled: true # what the agents I delegate to get mcp: serve: enabled: false skills: dirs: - dir: ~/.config/hyprpilot/skills/delegate ``` Distinct from `includeProfiles`, which answers *which* profiles you may reach. This answers *what those agents can reach* once running. Setting `harness.enabled: true` here does **not** give a delegate a harness — the depth gate is checked first and does not consult `enabled`. Setting `harness.max_depth` here **does**, because the gate reads `max_depth` from the block it is deciding on, and the overlay is folded into that block. That is the supported way to hand one specific delegation an extra level without raising the cap for everything. Because the fold is per key and not wholesale, a block naming only `skills.enabled` keeps the delegate's `skills.dirs`. Arrays replace rather than merge, so `autoAcceptTools` set here is the delegate's whole accept list. **Off by default, and that is a security property rather than a preference.** A profile's `command` is an arbitrary binary, so anything that can call `spawn` executes commands as you. Turn it on deliberately — see [Runtime → Agent Harness](../runtime/harness). ## Vendor projection The merged catalogue and policy are projected into each vendor's native shape at launch: | Vendor | Servers via | Policy via | | ------------- | -------------------------------------- | --------------------------------------------------------------- | | `claude-code` | `--mcp-config ` (0600 temp file) | `--allowedTools` / `--disallowedTools` (`mcp__server__tool`) | | `codex` | `-c mcp_servers..*` overrides | exact-name `enabled_tools` / `disabled_tools` / `approval_mode` | | `opencode` | `OPENCODE_CONFIG_CONTENT` env | ordered `OPENCODE_PERMISSION` rules (`server_tool`) | Codex does not support wildcard tool patterns in those fields, so wildcard patterns are skipped for Codex with a warning. Provider-native arguments you pass after `--` (or env you set on the agent) suppress the generated equivalents. ## Secrets in the vendor handoff MCP server entries commonly carry secrets — bearer tokens in HTTP `headers`, API keys in stdio `env`. hyprpilot keeps expanded secret material out of the vendor's **argv**, because a process's argv is world-readable through `/proc//cmdline` on Linux: * **`claude-code`** — the resolved MCP config (with `${VAR}` header/env references already expanded) is written to a per-launch **0600 temp file** and passed as `--mcp-config `. The file is created owner-only from the start, so the secret never lands in argv. hyprpilot `exec()`s into the vendor and does not delete the file first — the vendor needs to read it after the handoff; it is a launch-scoped temp the OS reclaims on tmp cleanup. * **`codex`** — bearer tokens are projected as `mcp_servers..bearer_token_env_var` / `env_http_headers` references (the env var name, not its value), so codex resolves the secret from its own environment. * **`opencode`** — the generated config rides the `OPENCODE_CONFIG_CONTENT` env var. Env is not world-readable like argv, but does inherit into child processes; this is the residual, lower-risk surface. --- --- url: 'https://hyprpilot.kilic.dev/config/multiplexer.md' --- # {{ $frontmatter.title }} When hyprpilot launches inside tmux or zellij, it renames the current window / tab to `hyprpilot@` right before `exec()` — so you can tell agent panes apart at a glance. ## Configuration It is on by default: ```yaml multiplexer: set_title: true # default; set false to opt out ``` | Field | Type | Default | What it does | | ----------- | ---- | ------- | -------------------------------------------------------------------------------------- | | `set_title` | bool | `true` | Rename the current tmux window / zellij tab to `hyprpilot@` before exec. | ## How it renames The rename shells out to the multiplexer's own CLI, not raw OSC escape sequences — those are gated by tmux/zellij settings, the CLI is not: ::: code-group ```sh [tmux] tmux rename-window 'hyprpilot@my-project' ``` ```sh [zellij] zellij action rename-tab 'hyprpilot@my-project' ``` ::: The `` is the base name of the **resolved** working directory — after `--cwd`, the profile's `cwd`, and the current-directory fallback have been applied. ## When the rename is skipped The rename proceeds only when **all** of these hold — any one skips it (logged at `debug`, never aborting the launch): * `set_title` is not `false`. * `HYPRPILOT_NO_TITLE` is unset or falsey. * hyprpilot is not running under an editor. ### `HYPRPILOT_NO_TITLE` — the explicit override Set `HYPRPILOT_NO_TITLE` to a truthy value (`1`, `true`, or any non-empty value other than `0` / `false`) to skip the rename unconditionally — independent of `set_title` and of editor auto-detection. This is the authoritative hook a launcher sets in the environment when it owns the pane itself (for example an nvim plugin like `sidekick.nvim` that sets a per-tool `env` block). Because `[multiplexer]` is a **root** config field, it can't be reached via `--with-config` (which patches the profile) — the env var is the right hook. ### Editor auto-skip When hyprpilot is spawned as a child job/terminal of an editor, that editor owns the multiplexer pane, so renaming it from underneath is wrong. hyprpilot auto-detects this from environment markers and skips the rename without needing `HYPRPILOT_NO_TITLE`: | Marker | Editor | | ------------------------------------ | ---------------------- | | `NVIM` | nvim ≥ 0.5 | | `NVIM_LISTEN_ADDRESS` | older nvim | | `INSIDE_EMACS` | Emacs | | `VSCODE_PID` / `TERM_PROGRAM=vscode` | VS Code | | `VIM` | vim (lower confidence) | ## Best-effort by design The rename never gets in the way of a launch: * Outside tmux/zellij it is a no-op, regardless of the flag. * Any failure (missing `tmux` binary, a denied action) is logged at `debug` and never aborts the launch. Because hyprpilot `exec()`s away immediately after, the title is a one-shot stamp — nothing keeps it updated afterwards, and your multiplexer's own automatic-rename settings take over as usual once the vendor exits. --- --- url: 'https://hyprpilot.kilic.dev/config/logging.md' --- # {{ $frontmatter.title }} Hyprpilot logs through `tracing`, **always to stderr** — debug and release builds alike, ANSI colors on. Stdout stays clean for machine-readable output like `profiles --json`. ## Configuration ```yaml logging: level: info # trace | debug | info | warn | error ``` | Field | Type | Default | What it does | | ------- | ---- | ------- | -------------------------------------------------------------------------------------------------------------------- | | `level` | enum | *unset* | One of `trace` / `debug` / `info` / `warn` / `error`. Applied only when `--log-level` and `RUST_LOG` are both unset. | `logging.level` is **not seeded** — leaving it unset lets the built-in `error` filter (below) own the default, so a fresh run is quiet (errors only) until you ask for more. Seeding a level in the compiled defaults would re-nullify the scoped `logging.level` filter, so the code fallback owns the default; set `logging.level` in your own config to raise verbosity. The filter is resolved from the loaded config **before** the tracing subscriber is installed, so `logging.level` (and the other sources) take effect on the very first line — including the "config loaded" line. Set `level: error` (or run with `--log-level error`) and hyprpilot emits nothing below `error`. ## Filter precedence The active filter is resolved from four sources, highest first: 1. `--log-level ` (or `HYPRPILOT_LOG_LEVEL`) — a single level: `trace`, `debug`, `info`, `warn`, or `error`. 2. `RUST_LOG` — a full [`tracing` env-filter expression](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html), for per-crate targeting. 3. `logging.level` in config — folded into the filter once the config is loaded, only when neither of the above spoke. 4. The built-in default — `error`, which keeps a fresh run quiet: only errors surface unless a level is explicitly requested. If you want file/line provenance on each log line, run with `--log-level debug` (or `trace`) — the `file:line` tagging rides only on those levels so the narrative stays terse. ## Why logs stop at exec Hyprpilot `exec()`s into the vendor CLI, so its own tracing only covers the brief resolve phase before hand-off: config load, profile resolution, MCP/skills registry construction, and the projection. Once the vendor TUI takes over the terminal, everything you see belongs to the vendor. That makes `--log-level debug` the go-to for launch problems — you get the whole resolve narrative, then the vendor starts (or the error that stopped it) with nothing else in between: ```sh hyprpilot engineer --log-level debug ``` --- --- url: 'https://hyprpilot.kilic.dev/runtime.md' --- # {{ $frontmatter.title }} Hyprpilot is a **config-driven, fire-and-exec launcher for terminal coding agents**, shipped as a single Rust binary. It resolves a session *profile* from layered config, projects that profile onto the chosen vendor's **native** CLI flags and environment, and `exec()`s into the vendor CLI — `claude`, `codex`, or `opencode` — replacing its own process. ## The fire-and-exec model A launch is one straight line: 1. **Resolve** — pick a profile (the positional `[PROFILE]` id, the configured default, or the interactive picker) and fold config layers, [`patches`](../config/patches), and [`--with-config`](./with-config) overlays onto it. 2. **Project** — translate the resolved profile (model, mode, system prompt, MCP catalogue, tool policy) onto the vendor's own flags and environment variables. 3. **Rename** — optionally retitle the current tmux window / zellij tab to `hyprpilot@`. 4. **`exec()`** — replace the hyprpilot process with the vendor CLI. On unix there is no child process left behind; the vendor TUI simply *is* your terminal from that point on. ::: info No daemon, no socket, no UI There is **no background daemon, no unix socket, and no window or desktop UI** anywhere in hyprpilot. Once the vendor CLI is running, hyprpilot is gone — it inherits your shell environment, hands over the terminal, and its exit code is the vendor's. ::: ## The one long-lived thing The components that outlive the launch are the in-tree **MCP servers** — `hyprpilot mcp serve` (general tools), `mcp skills` (the skill catalogue), and `mcp harness` (driving other agent sessions). The launcher auto-injects a stdio entry for each one your `mcp` config enables, and the vendor spawns those sidecars itself — so your `SKILL.md` catalogue reaches the agent over MCP. See [Skills & the hyprpilot MCP Server](./skills) and [Agent Harness](./harness). ## Why you would want it If you launch the same agent CLI with the same model, working directory, system prompt, and MCP servers every day, hyprpilot turns that incantation into a named profile: ```sh hyprpilot engineer # instead of a 200-character vendor invocation ``` If you switch between vendors, profiles keep each vendor's flag dialect out of your muscle memory — the same profile shape projects onto whichever provider the profile names. ## Where to go next * [Installation](./installation) — AUR packages or a source build. * [Quickstart](./quickstart) — a minimal config and your first launch. * [Launching](./launch) — the launch flags, the picker, and the environment knobs. * [Config](../config/) — the full configuration reference. --- --- url: 'https://hyprpilot.kilic.dev/runtime/installation.md' --- # {{ $frontmatter.title }} Hyprpilot is a single Rust binary. It resolves a session profile from layered config and `exec()`s into the vendor's native agent CLI — so the only hard runtime dependency is the vendor CLI you want to launch (`claude`, `codex`, or `opencode`). ## Arch Hyprpilot is published to the AUR in two flavors. Pick one — they conflict by design. ::: code-group ```sh [hyprpilot-bin] # Prebuilt binary — tracks the latest GitHub Release. yay -S hyprpilot-bin ``` ```sh [hyprpilot-git] # Builds from the latest `main` with cargo. yay -S hyprpilot-git ``` ::: Both install the binary, the terminal `.desktop` entry, and the hicolor icons. Swap `yay` for `paru` or your AUR helper of choice. ## Building from source If you are not on an Arch-like distro, build with a stock Rust toolchain — it is a plain Rust build with no webkit / gtk / node dependency: ```sh git clone https://github.com/hyprpilot/hyprpilot cd hyprpilot cargo build --release install -Dm755 target/release/hyprpilot ~/.local/bin/hyprpilot ``` A manual build only drops the binary; the AUR packages also install the desktop entry and icons. See [Development](../repository/development) for the pinned toolchain and `task` targets. ## The desktop entry The AUR packages install a terminal-launcher `.desktop` file at `/usr/share/applications/hyprpilot.desktop`: ```ini [Desktop Entry] Type=Application Name=hyprpilot GenericName=Agent CLI launcher Comment=Resolves a profile and execs your coding agent's native CLI. Exec=hyprpilot Icon=hyprpilot Categories=Development;Utility; StartupNotify=false Terminal=true ``` `Terminal=true` means any XDG app launcher (rofi, wofi, fuzzel, the GNOME grid) lists **hyprpilot**; selecting it opens a terminal, runs the interactive profile picker, and execs the chosen agent in that terminal. Nothing extra to wire — installing the package is enough. ## Launch from a compositor keybind If you prefer a keybind over an app launcher, bind a key to a terminal that runs hyprpilot. Bare `hyprpilot` opens the interactive picker; `hyprpilot ` skips straight to a profile. ::: code-group ```ini [Hyprland] # ~/.config/hypr/hyprland.conf bind = SUPER, RETURN, exec, foot -e hyprpilot bind = SUPER SHIFT, RETURN, exec, foot -e hyprpilot engineer ``` ```sh [Sway] # ~/.config/sway/config bindsym $mod+Return exec foot -e hyprpilot bindsym $mod+Shift+Return exec foot -e hyprpilot engineer ``` ::: Swap `foot` for your terminal of choice (`kitty -e`, `alacritty -e`, `wezterm start --`, `gnome-terminal --`, …). The vendor TUI takes over that terminal until you exit it. ## After install 1. **Install a vendor CLI** — hyprpilot launches `claude`, `codex`, or `opencode`; at least one must be on your `$PATH`. 2. **Configure at least one profile** — fresh installs ship agent defaults but **no** profile, and hyprpilot refuses to launch until one exists. The [Quickstart](./quickstart) walks you through it. Because hyprpilot `exec()`s into the vendor CLI, it inherits your shell environment as-is — API keys, `$PATH`, and everything else the vendor needs are already present when you run it from a terminal. There is no long-lived process to keep hydrated. --- --- url: 'https://hyprpilot.kilic.dev/runtime/quickstart.md' --- # {{ $frontmatter.title }} A fresh install already knows the three vendors — the compiled defaults seed `agents` entries for `claude-code`, `codex`, and `opencode` — but ships **zero** profiles, and hyprpilot refuses to launch until you configure one. This page gets you from nothing to a working launch. ## One profile Create `~/.config/hyprpilot/config.yaml`: ```yaml profile: default: engineer # which profile bare `hyprpilot` picks profiles: - id: engineer agent: claude-code # references a seeded agents id model: claude-sonnet-4-5 # optional; profile > agent > vendor default ``` That is the entire minimum: one `profiles` entry pointing at a built-in agent, and a `profile.default` naming it. ::: tip YAML, TOML, or JSON The docs write config in YAML, the recommended format. TOML and JSON work exactly the same — drop a `config.toml` / `config.json` instead. See [Config → Formats](../config/#formats). ::: ## Launch ```sh hyprpilot # resolves the default profile, then execs `claude` hyprpilot engineer # or address the profile explicitly (positional) ``` Hyprpilot resolves the profile, projects it onto the vendor's native flags (here: `claude --model claude-sonnet-4-5`), and `exec()`s — the vendor TUI replaces hyprpilot in your terminal. If you add more profiles and drop the `default`, bare `hyprpilot` opens an interactive fuzzy picker over them instead. ## Grow the profile Everything a launch needs hangs off the same `profiles` entry — working directory, system prompts, MCP servers: ```yaml profiles: - id: engineer agent: claude-code model: claude-sonnet-4-5 cwd: ~/code/my-project system_prompt: - file: ~/.config/hyprpilot/prompts/engineer.md mcps: - file: ~/.claude.json ``` See [Config → Profiles](../config/profiles) for the full override surface. ## Check your work ```sh hyprpilot profiles ``` lists every configured profile — the default marker, id, agent, and model — without launching anything. If your config has a typo, this is where you see the validation error naming the offending field. --- --- url: 'https://hyprpilot.kilic.dev/runtime/launch.md' --- # {{ $frontmatter.title }} The bare invocation **is** the launch — hyprpilot is one binary, there is no `run` subcommand, and the `profiles` and `mcp` subcommands round out the surface: ```sh hyprpilot [PROFILE] [flags] [-- ] hyprpilot profiles [--json] hyprpilot mcp serve # general tools (`open`) hyprpilot mcp skills [--skill-dir ]… # the skill catalogue hyprpilot mcp harness [--max-sessions ] # spawn/drive agent sessions ``` ## Picking a profile The profile is an **optional positional argument** — no `--profile`/`-p` flag: ```sh hyprpilot # interactive picker over configured profiles hyprpilot engineer # launch the `engineer` profile directly ``` Omit the positional and an interactive fuzzy picker (powered by `nucleo`) opens over your configured profiles — each row shows the default marker, id, agent, model, and cwd. The `profile.default` entry starts **pre-selected under the cursor**, so a bare `hyprpilot` followed by Enter launches your default. Cancelling the picker aborts the launch; a non-interactive terminal errors instead of hanging. Because subcommands resolve before the positional, `hyprpilot profiles` and `hyprpilot mcp` always run the subcommand — a profile literally named `profiles` or `mcp` is therefore not positionally addressable (rename it, or reach it through `profile.default` + the picker). ## Launch flags | Flag | Purpose | | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `[PROFILE]` | Positional session-profile id to resolve and launch. Omit to pick interactively. | | `-p, --prompt ` | Inline headless prompt — the non-pipe alternative to `echo … \| hyprpilot`. Forces headless. | | `-f, --file ` | Read the headless prompt from a file (`~` / `$VAR` / relative expanded). Mutually exclusive with `--prompt`. | | `--cwd ` | Working directory for the vendor process. | | `--mode ` | Mode override, projected onto the vendor where supported. | | `--resume[=]` | Continue a conversation — bare opens the vendor's session picker, with a value it resumes that session. | | `--resume-last` | Continue the most recent conversation, no picker. Mutually exclusive with `--resume`. | | `--with-config ` | Profile overlay patch (repeatable). See [Ad-hoc Overlays](./with-config). | | `--with-config-format ` | Format for stdin / inline / extension-less overlays (default `json`). | | `-- ` | Everything after `--` is forwarded verbatim to the vendor CLI; generated equivalents are suppressed. | ## Overriding per launch The profile is the single source of truth for its agent and model — there are no `--agent` / `--model` launch flags. The two per-launch knobs that remain touch only where the vendor runs and how, not what the profile *is*: ```sh hyprpilot engineer --cwd ~/code/foo # run somewhere else hyprpilot engineer --mode plan # vendor-specific mode ``` * `--cwd ` beats the profile's (or agent's) configured `cwd`; when neither speaks, the current directory is used. * `--mode` is projected onto the vendor CLI where supported. For a one-off model, agent, MCP set, or system prompt — anything that changes what the profile resolves to — reach for [`--with-config`](./with-config), the ad-hoc overlay escape hatch: ```sh hyprpilot engineer --with-config '@{"model":"claude-opus-4-5"}' # different model, one launch hyprpilot engineer --with-config '@{"agent":"codex"}' # different agent, one launch ``` ## Resuming a conversation `--resume` and `--resume-last` are vendor-neutral: hyprpilot maps the intent onto whatever the resolved profile's vendor calls it, so one keybinding works across every profile. ```sh hyprpilot engineer --resume # pick a session in the vendor's own picker hyprpilot engineer --resume-last # straight back into the most recent one hyprpilot engineer --resume= # a specific session ``` | Intent | `claude` | `codex` | `opencode` | | ------------- | --------------- | --------------- | ---------------- | | Picker | `--resume` | `resume` | **unsupported** | | Most recent | `--continue` | `resume --last` | `--continue` | | By session id | `--resume ` | `resume ` | `--session ` | Two refusals, both loud rather than silent: * **opencode has no session picker.** Its CLI registers only `--continue` and `--session `, so a bare `--resume` errors instead of quietly resuming something you did not choose. * **No picker survives a headless launch.** A picker needs a terminal to answer it, so combining `--resume` with `--prompt` / `--file` / piped stdin errors. Use `--resume-last` or `--resume=` there. Passing the vendor's own flag through `-- ` still wins — the generated projection is suppressed exactly as it is for every other flag. ## Forwarding native arguments Everything after a `--` separator is forwarded verbatim to the vendor CLI — use it for provider-native flags and resume flows: ```sh hyprpilot engineer -- --resume hyprpilot review -- --dangerously-skip-permissions ``` Any provider-native argument you pass this way suppresses hyprpilot's generated equivalent, so your hand-written flag always wins over the projection. ## Headless / prompt delivery Give hyprpilot a prompt and it launches the vendor **non-interactively** — one shot, then exit, like `claude --print`. There are three ways to supply the prompt: ```sh echo "fix the failing test" | hyprpilot engineer # piped stdin git diff | hyprpilot review # the diff becomes the prompt hyprpilot engineer --prompt "fix the failing test" # inline flag, no pipe needed hyprpilot engineer --file ./task.md # prompt read from a file ``` Headless is **effective** when any of these is true: `--prompt` / `--file` is given, the profile sets [`headless = true`](../config/profiles#headless), or stdin is a pipe (not a TTY). The **prompt source** resolves in priority order — an explicit `--prompt` / `--file` value wins over piped stdin (`--prompt` and `--file` are mutually exclusive; passing both errors). The full model / effort / mode / MCP / tool-policy projection — plus `--cwd`, `--mode`, and `--with-config` — still applies; headless only changes *how the prompt is delivered*, not *what the profile resolves to*. ### How the prompt reaches the vendor hyprpilot delivers the resolved prompt on the vendor's **stdin** where the vendor supports it, and as a positional argument otherwise: | Vendor | Projected invocation | Prompt delivery | | ---------- | ---------------------------------- | ----------------------------- | | `claude` | `claude --print` (prompt on stdin) | **stdin** (spawned, then EOF) | | `codex` | `codex exec` (prompt on stdin) | **stdin** (spawned, then EOF) | | `opencode` | `opencode run ""` | positional argument | For **claude** and **codex**, hyprpilot spawns the vendor, writes the prompt to its stdin, and closes the pipe (EOF). This is deliberate: claude's `--allowedTools` / `--disallowedTools` are **variadic** flags that would greedily swallow a trailing positional prompt as a tool entry, and a positional never reaches the model; stdin has no such ambiguity. **opencode** has no stdin prompt support (its `run [message…]` is positional-only), so the prompt stays a positional argument there. The interactive (non-headless) path always `exec()`s, unchanged. * **Profile selection.** A headless launch never opens the interactive picker (there may be no TTY, and stdin may be a consumed pipe). With no positional profile it resolves [`profile.default`](../config/profiles#picking-the-default) directly, and errors cleanly when no default is configured — pass a positional profile or set a default. * **Headless without a prompt.** If headless is forced (profile `headless = true`, or `--prompt`/`--file` — though those always carry a prompt) but no prompt resolves — e.g. `headless = true` on an interactive TTY with no pipe and no `--prompt`/`--file` — the launch **errors** rather than opening a picker it can't drive. An empty prompt (empty pipe, or empty `--prompt`/`--file`) errors too. * **`--with-config -` already drains stdin.** `--with-config -` reads the pipe to build the overlay, so the same pipe can't also be the headless prompt. Piping into a headless launch that also passes `--with-config -` **errors** with a targeted message (rather than misreporting an "empty prompt") — pass the prompt via `--prompt` / `--file` instead, or forward it through a trailing `-- `. * **Escape hatch — bring your own invocation.** When you pass the vendor's headless flags yourself via `-- …` **without** a `--prompt`/`--file`, hyprpilot does **not** read stdin — fd0 stays inherited so the vendor gets the raw pipe as input data, and the trailing args suppress hyprpilot's generated projection: ```sh cat data.json | hyprpilot engineer -- -p "summarize this" # → claude gets data.json on stdin AND "summarize this" as the prompt arg ``` Only the automatic path (no trailing `--` args) buffers stdin. * **`-p`/`-f` compose with `-- `.** An explicit `--prompt` / `--file` is a deliberate prompt, so it is **delivered even when you also pass trailing `-- `** — the two compose rather than being mutually exclusive. hyprpilot delivers the prompt on its usual vendor path (stdin for `claude` / `codex`, positional for `opencode`) **and** appends your `-- ` to the vendor argv, where the existing dedup lets a hand-passed flag suppress hyprpilot's generated equivalent: ```sh hyprpilot engineer -p "fix the bug" -- --allowedTools Read # → claude gets "fix the bug" on stdin AND `--allowedTools Read` on argv ``` Only the escape hatch **without** an explicit `--prompt`/`--file` skips stdin entirely. ## Global flags Available on every invocation, each with an environment twin, so you can pin them per shell or per session: | Flag | Env | Purpose | | ------------------------- | -------------------------- | ---------------------------------------------------------------------------- | | `--config ` | `HYPRPILOT_CONFIG` | Override the global config path (format inferred from the extension). | | `--config-profile ` | `HYPRPILOT_CONFIG_PROFILE` | Layer a named config-layer overlay (`profiles/.{ext}`). | | `--log-level ` | `HYPRPILOT_LOG_LEVEL` | Override the tracing filter (`trace` / `debug` / `info` / `warn` / `error`). | ```sh HYPRPILOT_CONFIG_PROFILE=work hyprpilot engineer ``` `--config-profile` layers a named config file (`~/.config/hyprpilot/profiles/.yaml`) on top of your global config — a **config-layer** profile, distinct from the session `profiles` you address with the positional `[PROFILE]`. See [Config → Layering](../config/layering). Log filter precedence is `--log-level` → `RUST_LOG` → `logging.level` → the built-in `error` default (a fresh run is quiet — errors only — unless a level is explicitly requested); tracing always writes to stderr. See [Config → Logging](../config/logging). **cwd reaches each vendor differently.** claude inherits the process working directory; codex takes `--cd`; opencode takes `--dir`. hyprpilot sets the process cwd *and* emits the flag for the two vendors that need one — opencode does not derive its tool sandbox from the process cwd, so without `--dir` an agent given a `cwd` silently worked in the wrong tree while every surface reported the requested path. A `--dir` / `--cd` you pass yourself after `--` suppresses the generated one. ## What a launch does 1. Load + validate layered config ([Config → Layering](../config/layering)). 2. Pick the profile (positional `[PROFILE]` → `profile.default` → picker) and fold [`patches`](../config/patches) + [`--with-config`](./with-config) overlays. 3. Build the per-launch MCP + skills registries, auto-injecting each in-tree server your `mcp` config enables ([Skills](./skills), [Agent Harness](./harness)). 4. Project everything onto the vendor's native flags/env ([Config → Agents](../config/agents)). 5. Optionally rename the tmux window / zellij tab ([Config → Multiplexer](../config/multiplexer)). 6. `exec()` — the vendor CLI replaces the hyprpilot process. ### cwd precedence The working directory the vendor launches in resolves as: explicit `--cwd` flag → the profile's (or agent's) configured `cwd` → the current directory. A profile pinned to a repo therefore launches there by default, and `--cwd` overrides it per invocation. ## Inspecting without launching ```sh hyprpilot profiles # table: default marker, profile, agent, model hyprpilot profiles --json # machine-readable hyprpilot --with-config '@{"model":"claude-opus-4-5"}' profiles # preview an overlay ``` The listing resolves config the same way a launch does — including [`patches`](../config/patches) **and any [`--with-config`](./with-config) overlay you pass** — but stops before exec, so the displayed summaries reflect what a launch *would* use. `--json` keeps stdout pure (all tracing goes to stderr), safe to pipe into `jq`. If a `patches` / `--with-config` overlay fails to resolve for a profile, that row is flagged with a `!` marker and the error message (in the table, the JSON gains an `error` field) instead of silently showing the un-overlaid base values — so a broken patch is never mistaken for the resolved shape. An empty `profiles` list is a validation error, not an empty table — fresh installs refuse to run until you configure at least one profile ([Quickstart](./quickstart)). A config typo aborts with an error naming the offending field path. ### Subcommands are not launches `profiles` and the `mcp` servers are subcommands, not launches, so **launch-only arguments do not apply to them** — the positional `[PROFILE]`, `--cwd`, `--mode`, and a trailing `-- ` are all rejected with a clear error rather than silently dropped: ```sh hyprpilot engineer profiles # error: positional does not apply to `profiles` hyprpilot --cwd /tmp profiles # error: --cwd does not apply to `profiles` ``` The one exception is `--with-config`: `profiles` honors it (the overlay preview above), while the `mcp` servers — which read none of the launch config — reject it too. Run the launch and the subcommand as separate invocations. ## Exit behavior Because a successful launch replaces the process, hyprpilot's own exit code is the vendor CLI's on unix (non-unix platforms fall back to spawn-and-propagate-exit-code). Config load failures, an empty `profiles` list, an unresolvable profile, or a missing `system_prompt` file abort before exec with a readable error naming the problem. --- --- url: 'https://hyprpilot.kilic.dev/runtime/with-config.md' --- # {{ $frontmatter.title }} If you want to bend a profile for a single launch — a different MCP set, one extra prompt, a model swap driven by a script — use the repeatable `--with-config` flag. Each value is a partial profile overlay folded onto the resolved profile, **after** the root [`patches`](../config/patches). ## Input shapes ```sh hyprpilot engineer --with-config ./overlay.yaml hyprpilot engineer --with-config '@{"model":"claude-opus-4-5"}' some-generator | hyprpilot engineer --with-config - ``` Each value is one of three shapes: * **a file path** — the extension (`.toml` / `.json` / `.yaml` / `.yml`) drives the format; * **`@`** — an inline literal in the current format; * **`-`** — read from stdin, usable **at most once** per invocation. The flag is repeatable; overlays fold in declaration order, later wins on field collision. ## `--with-config-format` `--with-config-format toml|json|yaml` drives stdin, inline, and extension-less inputs. It defaults to `json` — the best fit for CLI piping and inline one-liners: ```sh gh api …upstream-config… | jq '{mcps: [.]}' | hyprpilot engineer --with-config - ``` ## Merge semantics Overlays use the same strategic-merge engine as [`patches`](../config/patches) — object-field merge, keyed-array merge by `id`, primitive-array append + dedupe, and the `$patch: replace` / `$patch: delete` / `$deleteFromPrimitiveList/` directives. See [Config → Patches → Merge semantics](../config/patches#merge-semantics). ## Where it sits in resolution `--with-config` overlays are folded **after** the root `patches` — they are the most specific config layer. Because the profile owns its agent and model, `--with-config` is *the* way to change either for one launch (there are no `--agent` / `--model` flags); the only knobs applied on top of the resolved profile afterwards are `--mode` and `--cwd`. --- --- url: 'https://hyprpilot.kilic.dev/runtime/skills.md' --- # {{ $frontmatter.title }} Skills are `SKILL.md` bundles — reusable markdown instructions the agent can list, read, and reload. They reach the agent **only** through hyprpilot's own in-tree MCP server, which the launcher auto-injects into the vendor's MCP config. ## Skill bundles The skills catalogue is configured under the [`mcp` block](../config/mcp#the-mcp-block); each configured root is a flat directory of `/SKILL.md` bundles, compatible with [Anthropic's skill convention](https://github.com/anthropics/skills): ```txt ~/.config/hyprpilot/skills/ ├── git-commit/ │ └── SKILL.md ├── linear-issue/ │ ├── SKILL.md │ └── references/ └── github-pr/ └── SKILL.md ``` Per-root `ignore` globs skip matching slugs at load. On a slug collision across roots, the first root wins. Missing roots warn and are skipped. The compiled defaults seed the XDG skills root `~/.config/hyprpilot/skills` (via a root [`patches`](../config/patches) entry), and the built-in `mcp` defaults (`enabled: true`, `autoAcceptTools: ['*']`) fill in the rest — so skills work out of the box once you drop a `SKILL.md` in. A profile's own `mcp` block wholesale-replaces the global one — point a profile at a different skills root, or disable the server entirely. ## Auto-injection When `mcp.enabled` is `true`, `mcp.skills.enabled` is `true` (the default), **and** the resolved skills catalogue is non-empty, hyprpilot prepends a stdio MCP server named **`hyprpilot-skills`** to the catalogue it hands the vendor. That entry launches `hyprpilot mcp skills` as a child of the agent — the vendor owns its lifetime; you never run it by hand. * The reserved name replaces any same-named server you configured. Rename it with `mcp.skills.name`. * Auto-inject is independent of `mcps` — `mcps: []` does not suppress it. Set `mcp.skills.enabled: false` (this server only), `mcp.enabled: false` (all three in-tree servers), or leave the skills catalogue empty to turn it off. * This is the one server also gated on **content**: no discovered skills means nothing is injected, since there would be nothing to serve. * `autoAcceptTools` / `autoRejectTools` default the approval policy for the injected server; the default `['*']` accept makes skill calls frictionless. The injected entry runs the current binary with one `--skill-dir` argument per configured root, each carrying that root's own ignore-glob list as JSON — see [the `mcp skills` reference](#hyprpilot-mcp-skills) below for the exact shape. ## What the server exposes `hyprpilot mcp skills` is a small [rmcp](https://github.com/modelcontextprotocol/rust-sdk) stdio server. Skills are exposed as MCP resources: * `hyprpilot://skills` — the **catalogue index**: every skill with its description, as one markdown document, led by a header explaining how to load them. Attach it (`@`-mention it, or whatever your client calls that) and it costs **no** tool call — the client injects it directly. A model *can* also pull it where the client exposes generic resource reading (Claude Code has `ReadMcpResourceTool`), but that is still a tool call, so for the model `list_skills` remains the better route: same cost, and it is a named tool with a description to route on rather than a URI it must already know. The resource's win is the attachment path. * `hyprpilot://skills/` — the skill body, followed by a manifest of the references it declares: each one's path and name, but not its body. ::: warning References have no URI, and that is a context-budget decision The resource surface is the catalogue index and one entry per skill. Nothing else. Reference bodies are reached only through `read_skill_references`. Measured against a real 127-skill catalogue: listing one entry per skill costs 128 resources and ~105 KB. Adding one bundle entry per skill took it to 231 and ~170 KB, of which 48% was `_meta` — each bundle entry repeating its own skill's block verbatim, paying twice for one skill's metadata. Enumerating all 479 individual references on top would reach **~607 entries and ~500 KB, over 120k tokens spent before a single skill is read**. A URI would also be the wrong shape. A reference's identity is its path; a `/` address is one of many addresses for one shared file, which is exactly what makes double-loading invisible. ::: And as tools: | Tool | Purpose | | ----------------------- | -------------------------------------------------------------------- | | `list_skills` | Enumerate discovered skills with their metadata and reference count. | | `read_skill` | Fetch a skill body by slug, plus its reference manifest. | | `list_skill_references` | One skill's reference metadata, without bodies. | | `read_skill_references` | Fetch reference bodies by path. | | `reload` | Rescan the skill roots (picks up edits / new bundles). | ### What `reload` tells connected clients Results carry a `ttlMs` of 24 hours — longer than a sidecar lives — so a client caches until told otherwise. `reload` earns that by **diffing** the catalogue and firing only what actually changed: | What you changed | What fires | | ----------------------------- | --------------------------------------------------------------------------------------------------- | | A skill's body or frontmatter | `resources/updated` for that skill's URI and for the catalogue index, plus `resources/list_changed` | | Added or removed a skill | `resources/list_changed`, plus `resources/updated` for the index | | Nothing | nothing — a no-op reload never invalidates a client's cache | The tool result reports the same thing (`{ reloaded, membershipChanged, updated }`), so you can see what a reload actually moved. A client on `2026-07-28` opts in with `subscriptions/listen` (`resourcesListChanged` and/or `resourceSubscriptions`), and its notifications then ride that stream, tagged with the subscription id. A client with no stream — anything on an older revision — receives them as plain unsolicited notifications, exactly as before. `resources/list_changed` fires on **any** change, not only on membership, precisely so a client that cannot subscribe still has a signal it can act on: a body edit would otherwise reach it only as a `resources/updated` it has no way to have asked for. ::: warning Known gap: reference-only edits The diff compares each skill's body and frontmatter. A skill's resource read also carries a footer listing its references' sizes and modification times, and editing only a reference file changes that footer without changing the skill — so no `resources/updated` fires for it. Resolving every declared reference on every reload would mean reading every cited file of every skill, which is the cost the manifest design exists to avoid. Fetch reference bodies with `read_skill_references`, which always resolves from disk. ::: Reload refreshes the **sidecar**, not anything already in an agent's context — a skill body read earlier this session stays as it was until re-read. ## References A skill declares its references in frontmatter, as paths relative to the skill's own directory: ```markdown --- title: git-commit description: Stage and commit changes references: - ../references/commit-style.md - ../references/output-diff.md --- ``` ### The path is the address, and the identity `read_skill` returns the skill body plus a **manifest** — every declared reference, with the canonical path that fetches it — but not their bodies: ```jsonc { "uri": "hyprpilot://skills/git-commit", "body": "…", "references": [ { "path": "/home/you/.config/hyprpilot/skills/references/output-diff.md", "name": "output-diff", "size": 2481, "modified": "2026-08-04T09:12:33Z", "created": "2026-05-02T11:04:07Z" } ] } ``` Pass those paths back to fetch bodies: ```jsonc read_skill_references { "references": ["/…/references/output-diff.md"] } // body plus everything, in one call read_skill { "slug": "git-commit", "bundle": true } ``` Addressing by path rather than by skill-and-name buys three things: * **De-duplication.** The same shared file is cited by many skills under different names. Two citations resolve to one path, so a path you already loaded needs no second fetch — and the server serves a repeated path once. * **One call across skills.** A path names a file, not a skill, so a single call fetches references belonging to as many skills as you like. * **No collision rules.** Paths are unique by construction, so two references sharing a label inside one skill are both fully addressable. There is nothing to shadow and no first-wins rule to remember. Only paths that some skill actually declares are served — a caller-supplied path is checked against that set, never joined onto anything, so the surface reaches exactly the files the skills already reference. Anything else is an error rather than a partial result. The **declared** spelling (`../references/output-diff.md`) never reaches the wire: it is meaningless outside its bundle directory, and offering it alongside the canonical path would give a caller two addresses of which only one works. Paths are canonicalized, so `..` collapses and two spellings of one file compare equal. `list_skill_references { slug }` returns the same manifest without the skill body, for checking what a skill cites before spending tokens on it. It takes a slug rather than scanning the whole catalogue — a corpus-wide scan is a six-figure payload, and comparing paths per skill answers the same question incrementally. Because the manifest always rides along — including as a text footer on the resource path, for clients that never surface `_meta` — declining a body is never a silent gap. The reader can always see what exists and what it has not loaded. ### Missing files and reference frontmatter * **Missing file:** a reference that is declared but cannot be read appears in the manifest and in any bundle as a `status: not-found` marker **in its declared position**, so the gap is visible where it belongs. It has no path, so it cannot be fetched. * **Reference frontmatter:** a reference may carry its own YAML frontmatter, parsed exactly as a skill's is. It is served with the fence stripped and its keys projected into the manifest entry's `metadata` — nothing is invented into it, because hyprpilot enforces no invocation gate and a stamped `disableModelInvocation` would imply a restriction that does not exist. A `name:` there overrides the display label. A fetched reference carries its **full** metadata: the bundle header is built from the same manifest row the listing advertises, so the two cannot disagree. ```txt --- reference: path: /home/you/.config/hyprpilot/skills/references/output-diff.md name: output-diff size: 2011 modified: 2026-08-10T12:08:46Z created: 2026-08-10T10:32:30Z --- # Output Diff … ``` Full detail is affordable there and not in a listing: it is emitted once per reference you deliberately asked for, whereas `resources/list` pays for the whole catalogue. ### Timestamps Skills and references both carry `size`, `modified`, and `created` as RFC 3339 UTC strings, so an agent can tell a convention it read last week from one that changed an hour ago. `created` is the filesystem birth time and is **omitted** where the platform or filesystem does not record one, rather than being back-filled from `modified` — that would answer a different question than the key names. Access time is deliberately absent: it records reads rather than writes, and lazily on the `relatime` mounts that are the Linux default. ## Frontmatter passthrough A `SKILL.md` is markdown with an optional YAML frontmatter block. The loader keeps **every** frontmatter key losslessly, and the server passes the map through to the agent on the MCP wire so a new frontmatter field reaches the agent with zero server changes. Metadata is carried in **one** block — never duplicated across surfaces. Per the MCP spec, `_meta` is a single field keyed by reverse-DNS names; hyprpilot emits exactly one such key and never repeats anything the spec-compliant `Resource` fields already carry: * **Spec `Resource` fields** are canonical: `uri`, `name` (the slug), `title`, `description`, `mimeType`, `size`. * **`io.hyprpilot/skill`** (resource `_meta`) / **`metadata`** (tool output) — the same single block: the entire frontmatter map **verbatim** (keys pass through unchanged — no camelCasing; nested maps, arrays, numbers, and booleans all convert), **minus** the keys another field already carries, **plus** the runtime-derived `path`, `bundleDir`, `size`, `modified`, and `created` (which are not in the frontmatter). Two frontmatter keys are dropped as duplicates. `title` and `description` equal the canonical `Resource.title` / `Resource.description` byte-for-byte. `references` is superseded by the resolved [reference manifest](#references), which addresses each one by its canonical path. The raw array holds the *declared* spelling (`../references/output-diff.md`), which is meaningless outside its bundle directory and cannot be passed to `read_skill_references` — publishing both would offer a caller two addresses of which only one works. Frontmatter `name` is **kept** in the block — `Resource.name` is the slug, while a frontmatter `name` is an author-supplied value that may differ, so it is not a spec duplicate. Frontmatter that isn't map-shaped, or a `SKILL.md` with no frontmatter fence at all, is treated as an empty map — a malformed block never fails the request. ::: details Example — every key reaches the agent This `SKILL.md`: ```markdown --- name: plan-hard title: Plan hard description: Deep planning disable-model-invocation: true metadata: owner: captain tags: [alpha, beta] --- # Plan hard …skill body… ``` …reaches the agent with `title` / `description` on the spec `Resource` fields, and every other key (`name`, `disable-model-invocation`, the nested `metadata` map) plus the runtime `path` / `bundleDir` intact under the single `io.hyprpilot/skill` block — `title` and `description` are **not** repeated inside it. ::: ## `hyprpilot mcp skills` The subcommand that runs the server over stdio. **You don't run this by hand** — the agent vendor spawns it as a child via the auto-injected entry. ```sh hyprpilot mcp skills --skill-dir '{"dir":"/abs/path","ignore":[]}' ``` | Flag | Purpose | | -------------------- | ------------------------------------------------------------------------------------ | | `--skill-dir ` | JSON-encoded skill root entry. Repeatable — roots are searched in declaration order. | Each `--skill-dir` value is one self-contained JSON object: ```json { "dir": "/abs/path", "ignore": ["glob1", "glob2"] } ``` The launcher passes one `--skill-dir` per resolved skills root, each carrying that root's own ignore-glob list, so the sidecar rebuilds exactly the registry the launcher resolved — first-slug-wins on collision, per-root ignores applied independently. The [global flags](./launch#global-flags) apply here too; the server owns stdin/stdout for the MCP protocol, so logs go to stderr as everywhere else. --- --- url: 'https://hyprpilot.kilic.dev/runtime/harness.md' --- # {{ $frontmatter.title }} `hyprpilot mcp harness` is a control plane for hyprpilot itself: a connected agent can list your configured profiles, launch one as its own agent session, talk to it across turns, watch it work, and kill it. It is its own stdio server, alongside [Skills](./skills) and the general-tools server — separate process, separate catalogue entry, separate tool policy. ## What it solves Without the harness, an agent connected to hyprpilot's MCP servers can only read skills — it has no way to act as an orchestrator that spins up other hyprpilot sessions. The harness adds that: discover profiles, start one, send it follow-up turns, follow its output live, stop it. Every launch flows through the same `spawn::prepare` path `hyprpilot ` itself uses, so profile resolution, the `-- ` escape hatch, and cwd precedence can't drift between a CLI launch and a harness-driven one — see [Launching](./launch). ## Gating the harness ```yaml mcp: harness: enabled: true ``` **Off by default, deliberately.** hyprpilot auto-injects its in-tree servers into **every** launch (see [Skills → Auto-injection](./skills#auto-injection)), so an ungated spawn surface would let any agent session spawn nested sessions with no bound. A profile's `command` is an arbitrary binary — its `provider` only picks which native-flag projection applies, not a sandbox — so anything that reaches `spawn` executes commands as whoever runs the sidecar. Enable it only where that's intended, e.g. a gateway host that opts in explicitly. The gate is **structural** within the served surface. The harness is its own subcommand and its own `ServerHandler`, so the skills server has no `spawn` implementation to reach even if a client guesses the name. (An earlier design put these tools on the skills server behind a `--with-harness` flag, which meant remembering to gate both `list_tools` and `call_tool` — dispatch is by name, so gating only the listing would have left every tool callable. A reviewer caught exactly that half-missing gate.) ::: warning What the gate is not `mcp.harness.enabled` controls whether **hyprpilot** injects the entry — it bounds what a connected agent can *discover*, not what it can *do*. `hyprpilot mcp harness` is an ordinary subcommand: run it and it serves `spawn`, whatever your config says. That is deliberate — a hand-configured MCP entry (a gateway managing its own catalogue) must work without a hyprpilot config to consult, and `mcp` deliberately skips config validation so a broken config can't kill a sidecar the vendor keeps respawning. So this is **exposure reduction, not a capability boundary**. Against an agent that can run shell commands it buys nothing: such an agent can start the harness itself, or skip it entirely and run `hyprpilot `. It is a real boundary only against a client whose sole reach is MCP — which is the case worth protecting, since that is what an MCP-only gateway looks like. ::: Because it is a separate catalogue entry, it also gets its own tool policy — worth tightening, since `spawn` is the tool that runs arbitrary binaries: ```yaml mcp: harness: enabled: true autoAcceptTools: - list_profiles - session_read autoRejectTools: - spawn ``` ## Which profiles it can drive The harness runs **only the profiles you nominate.** A profile is available when it declares a `harness` block; without one it is absent from `list_profiles` and refused by `spawn` / `session_send`: ```yaml profiles: - id: personal/engineer agent: claude-code harness: enabled: true ``` Opt in a whole family with a `$match`ed [patch](../config/patches) rather than repeating it: ```yaml patches: - $match: profile: 'personal/*' harness: enabled: true ``` Default-deny because `spawn` runs a profile's `command` as you. See [Profiles → Putting a profile on the harness](../config/profiles#putting-a-profile-on-the-harness). ### Scoping delegation per launch Nomination above is **global** — it says a profile may be driven, not by whom. To narrow that per launch, scope the harness on the profile doing the orchestrating: ```yaml patches: - $match: profile: 'personal/*' mcp: harness: enabled: true includeProfiles: - 'personal/*' excludeProfiles: - 'personal/codex/*' ``` A session launched as `personal/kilic/glm-5.2` now sees and can drive only `personal/*`, minus anything `excludeProfiles` catches — `work/*` is absent from `list_profiles` and refused by `spawn` / `session_send`. * Both lists are globs over profile ids, matched with `globset`, so `*` **crosses `/`** exactly as `$match.profile` does — `personal/*` reaches `personal/kilic/glm-5.2`. * `excludeProfiles` beats `includeProfiles` on overlap, mirroring `excludeTools` / `autoRejectTools`. * Unset means no filter. `includeProfiles: []` means delegate to nothing — the server is still injected, it just has no candidates. * The two gates **AND**. A glob here can never promote a profile that never declared `profiles.harness`; it can only narrow what is already nominated. Like `enabled`, this bounds **discovery, not capability**: it decides what the auto-injected sidecar exposes. An agent that can run commands can start `hyprpilot mcp harness` itself, or skip it and run `hyprpilot ` directly. It is a real boundary for an MCP-only client, and a guard against a work session reaching personal profiles by accident — not containment. One corollary worth stating: a scoped-out id that **is** configured refuses differently from one that isn't (the latter falls through to the resolver's "unknown profile"), so an agent can probe which ids exist even when they are absent from `list_profiles`. That is the price of leaving "unknown profile" to the resolver instead of reporting a misleading scope refusal for a typo — the scope hides profiles from view, it does not make them unguessable. ## The tools | Tool | Purpose | | ---------------- | ----------------------------------------------------------------------------------------- | | `list_profiles` | Discover the profiles you can launch — vendor, model, effort, mode, cwd. Start here. | | `spawn` | Start a new session from a profile and send it a prompt. | | `session_send` | Send another message to an existing session, resuming it first if it's finished. | | `session_list` | List this server's sessions — handle, profile, status, exit code, timestamps. | | `session_status` | One session's state without its transcript — the cheap poll. | | `session_read` | Read, and optionally follow live, a session's transcript. | | `session_kill` | Stop a running session and everything it started — or reap one that has already finished. | ### Workflow 1. **`list_profiles`** to find an `id` — a row marked `!` failed to resolve; don't launch it. 2. **`spawn { profile, prompt }`** to start a session. It returns a `session` handle straight away and the agent keeps working — that handle is the session's identity for every later call. Pass `wait: true` to block instead, worth it only for a turn you expect to be short: past `timeout_seconds` the result comes back with status `running`, a `nextCursor` to resume reading from, and the agent still working. 3. **`session_status { session }`** until it reports `exited` — do **not** call `spawn` again for the same conversation. It reads no transcript, and its `transcriptBytes` tells you whether a running agent is progressing or wedged, which `status` alone cannot. To watch the output as it arrives instead, follow with `session_read { session, wait: true }`. 4. **`session_read { session }`** for the transcript once it has finished. 5. **`session_send { session, prompt }`** for every follow-up turn, once the session has finished its previous one. 6. **`session_kill { session }`** to stop a runaway agent, or to free a slot when `spawn` reports a `max_live_sessions` ceiling. It is state-aware, like `session_send`: on a **running** session it terminates the agent and keeps the transcript, so you can still read why; on an **already-finished** one it reaps the session and its transcript. Calling it twice is the natural stop-then-clean-up, and the result's `action` says which happened. 7. **`session_list`** any time you need to recover a handle you lost. ### The handle is the session's id `spawn` mints the `session` handle itself, before the vendor has produced a byte, and it is the only identifier any tool here accepts. It does not change across turns. Vendors mint their own session ids too — and hyprpilot captures one, because it is what `session_send` hands back to the vendor to continue the conversation. It is deliberately **not** on the wire. It would be a second id that identifies the same thing while behaving worse: absent for the whole first turn, appearing only once the vendor emits it, and addressing nothing, since no tool takes it. A caller that saw both would have to learn which one to use and when the other becomes available. There is one id, and you have it from the first result. ### Tasks (SEP-2663) — the opt-in parallel path The harness speaks the MCP Tasks extension **alongside** its own tools, never instead of them. A client that declares `io.modelcontextprotocol/tasks` gets a task handle from `spawn` / `session_send` and polls `tasks/get`; every other client gets exactly the result it always got. There is no config switch — the client's own declaration is the entire gate, and rmcp independently refuses to send a task to a peer that did not declare one. ```jsonc // spawn, from a declaring client { "resultType": "task", "taskId": "1a3615c8-5dfa-4613-892b-fe27f25e0f9d:1", "status": "working", "pollIntervalMs": 2000, "_meta": { "io.hyprpilot/session": "1a3615c8-5dfa-4613-892b-fe27f25e0f9d" } } ``` **A task names one TURN, not the session.** The spec makes `completed` / `failed` / `cancelled` terminal — once reached, a task's state never changes — while a session handle is reused across turns and cycles `exited → running → exited`. Keyed by the handle alone, starting turn 2 would rewrite turn 1's finished task. So the id is `:`, and a completed turn keeps reporting `completed` however far the conversation moves on. The session handle rides `_meta` rather than being parsed out of the task id: every other tool here takes the handle, and an id you have to take apart is not opaque. `tasks/cancel` cancels **that turn**, not the session. Terminal states are immutable, so cancelling a task that already finished is a no-op — deliberately, because routing it through `session_kill` (which reaps an already-finished session) meant a spec-legal cancel of a completed task killed the running turn and deleted the transcript. An unknown handle is `-32602`, matching `tasks/get`. **Task ids do not outlive the sidecar.** SEP-2663 presents a task id as a durable handle you can resume polling after a client restart; that assumption does not hold here. Sessions die with `hyprpilot mcp harness`, and finished ones are also dropped by `max_sessions` eviction and by `session_kill`. `ttl_ms` is `null` because retention is bounded by count and by process lifetime, not by a duration — any number would be a stronger promise than we can keep. `tasks/update` is unimplemented (`-32601`): the harness never emits `input_required`, so no task can have outstanding `inputRequests`. **What this does not give you.** `notifications/tasks` is pushed when a turn ends, but rmcp will not route task notifications through `subscriptions/listen` (`SubscriptionFilter` has no `taskIds` field yet), so a client that does not handle the method drops it silently — the same contract as the Claude channel. Polling `tasks/get` is the supported path today. **Which clients?** None of the three vendor CLIs declares the extension as of claude 2.1.220, codex 0.146.0 and opencode 1.18.11 — measured against a real handshake. This exists so that the day one does, hyprpilot already speaks the standard protocol. ### `session_status` | Field | Type | When | What it means | | ----------------- | ------ | ----------- | ---------------------------------------------------------------------------------------- | | `status` | string | always | `running` or `exited`. A session is `exited` after every **turn**, not only at the end. | | `exitCode` | int | once exited | Omitted while running. | | `turn` | int | always | Which turn of the conversation this is, 1-based. Also the suffix of that turn's task id. | | `transcriptBytes` | int | always | Bytes written so far. A number that stops moving is a wedged agent. | | `hasResult` | bool | always | Whether the agent's final answer has landed — see below. | `hasResult` is `false` for any running session, and only then scanned per vendor. Both halves matter: * opencode emits a `text` part for **every** completed sentence, not just the final answer, so content alone cannot say "done". * The scan reads only the current turn's own file, so an earlier turn's marker cannot make a running turn read as finished. The three vendors mark completion differently — all verified against the installed CLIs: * **claude** — a terminal `{"type":"result"}` carrying the answer. * **codex** — `{"type":"turn.completed"}` closes the turn; the text rode the `item.completed` before it, whose `item.type` is `agent_message`. * **opencode** — emits **no** terminal marker at all. Its stream ends `step_finish(reason=stop)`, so the last `{"type":"text"}` part is the signal. ### The session directory Each session owns a 0700 temp directory. Every file in it is named on `spawn` / `session_send` / `session_read` results under `sessionInfo.files`, so nothing has to be derived from a sibling path: | Key | File | What it is | | ------------ | ----------------------- | ---------------------------------------------------------------------------------------- | | `dir` | — | The directory itself. Gone once the session is reaped, evicted, or the sidecar exits. | | `transcript` | `turns//turns.jsonl` | The vendor's raw JSON event stream for THIS turn. | | `stderr` | `stderr.log` | The vendor's stderr. Surfaced in results only when non-empty. | | `done` | `done.json` | The completion marker — see below. | | `breadcrumb` | `session.json` | Crash-recovery state (pid, pgid, owning sidecar, start ticks) read by the startup sweep. | **`transcript` is there so you can read it directly.** `session_read` pages it for you, but an agent with shell access is often better off with `jq` — the answer sits in a different event per vendor, and the `tool_use` events in between can be enormous: ```sh T=$(…sessionInfo.files.transcript…) jq -r 'select(.type=="result") | .result' "$T" # claude jq -r 'select(.type=="item.completed") | select(.item.type=="agent_message") | .item.text' "$T" # codex jq -r 'select(.type=="text") | .part.text' "$T" # opencode ``` Everything under `files` is a path into a directory that can disappear — treat a missing file as "the session was cleaned up", never as an error. ### Being woken instead of watching Every session is also a **resource** — `hyprpilot://sessions/` — so a client can subscribe to one handle and be notified when its turn ends, with no polling and no shell watcher: ```jsonc // once, after `spawn` returns the handle {"method": "subscriptions/listen", "params": {"notifications": {"resourceSubscriptions": ["hyprpilot://sessions/"]}}} // when the turn ends {"method": "notifications/resources/updated", "params": {"uri": "hyprpilot://sessions/"}} ``` ### The resource surface | URI | What it returns | Cacheable | | ------------------------------------------ | ------------------------------------------------------------- | -------------- | | `hyprpilot://profiles` | What `list_profiles` returns, same delegate scope | no — see below | | `hyprpilot://sessions` | What `session_list` returns | no — see below | | `hyprpilot://sessions/` | What `session_status` returns — state, exit code, `hasResult` | when exited | | `hyprpilot://sessions//result` | **The latest turn's answer**, or why there isn't one | when exited | | `hyprpilot://sessions//transcript` | The raw event stream, capped | when exited | | `hyprpilot://sessions//stderr` | The vendor's stderr | when exited | Reading `hyprpilot://sessions/` also lists every turn and how it ended, each with the URI that fetches it: ```jsonc { "turn": 2, "hasResult": true, "status": "exited", "turns": [ { "turn": 1, "status": "exited", "exitCode": 0, "uri": "hyprpilot://sessions/…/turns/1/result" }, { "turn": 2, "status": "exited", "exitCode": 0, "uri": "hyprpilot://sessions/…/turns/2/result" } ] } ``` So one read answers "which turns exist and which is worth fetching", rather than walking `…/turns//status` until one errors. A killed turn reports `"status": "killed"` and no exit code — the `-1` a kill produces says nothing. The **un-turned** forms are the shortcut to the current turn: `…//result` is the latest answer with no turn number to look up. Reach for a turn-scoped URI only when you want an earlier one. Every view is also addressable **per turn** — `hyprpilot://sessions//turns//result` and the same for `status`, `transcript`, `stderr`. This is how an earlier turn's answer stays reachable once later turns have run. Turn numbers are 1-based; one the session never reached is an error, not an empty read. `resources/list` names the two indexes and **one entry per session**, not one per view. The views are advertised as the template `hyprpilot://sessions/{handle}/{view}` instead — four views across 64 retained sessions would be 256 rows every client pays for on connect. Both indexes carry `ttlMs: 0`. `hyprpilot://sessions` embeds each session's live status and nothing fires `resources/updated` for the index URI — `list_changed` invalidates `resources/list`, not this read. `hyprpilot://profiles` comes from config re-read per call and **nothing watches that file**. A surface that cannot signal must not claim freshness. `done.json` and the crash breadcrumb are deliberately not resources. The status view answers what `done.json` answers, and `done.json` exists precisely as the one signal a shell watcher can reach without MCP. The breadcrumb is orphan-debugging plumbing. Each turn reads its own file, so no boundary has to be found. Nothing in a transcript marks where a turn begins, and a turn that *dies* emits no terminal event at all — when turns shared a file, two turns' output was indistinguishable, which is what the per-turn layout retires. `/result` is the one that saves work. It does server-side what the `jq` recipes did by hand: finds the vendor's answer event, scopes it to the **latest** turn, and joins multi-block answers. It never comes back blank for a finished session — the three ways a run produces no answer each report themselves: | What happened | What `/result` says | | --------------------------------------- | --------------------------------------------------- | | Upstream failure (auth, quota, model) | the `error` event's message | | Launch failure (vendor rejected a flag) | `failed to launch`, plus the usage dump from stderr | | Exited with nothing at all | `exited with code N and produced no answer` | That matters because those land in *different places* — an upstream error is inside the transcript, a launch failure is in `stderr.log` with the transcript empty — so checking only one reports "the agent returned nothing" for a billing error. Two ways that goes wrong by hand, both silent, are structurally impossible here: it slices by event rather than by line (so a multi-line answer can't be truncated to its last line), and it never reports an earlier turn's answer as the reply to this one. `/transcript` is capped and truncated from the front, since the answer is at the end. A resource read has no cursor, so **`session_read` remains the way to page a long one**. An unknown view is refused rather than read as the status. ::: tip TTL depends on whether it's still moving A finished session's views carry the 24-hour TTL. A **running** session's carry `ttlMs: 0` — its result and transcript change under you, and the notification that fires at turn end cannot retroactively correct a day-long cache taken a second earlier. ::: `resources/list` enumerates sessions as handle / profile / status. Useful for recovering a handle, but the point is subscribing to one you already hold. **Every older mechanism still works, unchanged.** `notifications/claude/channel` still fires for Claude Code, `notifications/tasks` still pushes to clients that took a task handle, `session_status` is still the cheap poll, and `done.json` is still there for shell watchers. The subscription is an addition, not a replacement — a client that opts into nothing behaves exactly as before. Results carry `ttlMs` of 24 hours — longer than a sidecar lives — so a client that honours it re-fetches only when notified. Every change that invalidates a cached read is announced: a turn starting or ending, a `spawn` or a reap moving the list. **Two delivery channels, chosen per notification.** With a `subscriptions/listen` stream open, notifications ride that stream — filtered to the URIs you subscribed to and tagged with `io.modelcontextprotocol/subscriptionId`, which is what a conforming client correlates on. With no stream, they are sent as plain unsolicited notifications, which is the only channel a client on an older revision has and exactly what it received before. **Opening a stream replaces broadcasts entirely.** Anything outside your accepted filter is dropped, not delivered some other way — the filter is a declaration, and honouring it means honouring the parts you left out. So subscribe to `resourcesListChanged` too if you want to hear about sessions appearing and disappearing, not just the handle you named. Multiple streams may be open at once; each is filtered and tagged independently, so cancelling one never silences another. ### Watching from a shell **Every turn owns a directory**, and the layout is what makes the rest of this page work: ```txt /tmp/hyprpilot-session-XXXX/ ├── session.json crash-recovery breadcrumb (session-scoped) └── turns/ ├── 1/ │ ├── turns.jsonl this turn's transcript │ ├── stderr.log this turn's stderr │ └── done.json this turn's completion marker └── 2/ … ``` `sessionInfo.files` names the session's paths plus **this turn's** — `dir`, `turnsDir`, `turn`, `turnDir`, `transcript`, `stderr`, `done`, `breadcrumb`. Earlier turns are not listed: they are `//` for every `n` up to `turn`, which is inferable, and enumerating them would grow the payload with every turn while saying nothing new. A turn's output being its own file is why reading turn 1 cannot reach into turn 2, why "stderr is non-empty" means *this* turn wrote it, and why a fresh turn needs no marker cleared before it starts. The rest of `sessionInfo` is turn-scoped for the same reason: `model` / `effort` / `mode` / `argv`, `pid` and `turnStartedAt` all come off the turn's own record, not off the session. SEP-2663 makes a completed task's result immutable and a task names a **turn**, so a caller that re-polls a finished task after a later turn started must get back the bytes it got the first time. Each turn gets its `done.json` when its process exits, written by the same `child.wait()` task that owns the truth — so no recycled PID and no zombie can produce a false reading. This is the vendor-neutral completion signal, and the one a **shell** watcher can use, since a bash loop cannot call an MCP tool: ```bash [ ! -d "$TURN_DIR" ] || [ -f "$TURN_DIR/done.json" ] ``` Both halves are required. The marker is advisory: reaping, eviction and shutdown remove the whole session directory, so a watcher that only tests for the file waits forever on a session that was cleaned up. `{"handle": "…", "exitCode": 0, "finishedAt": 1785584247}` Arm it on `turnDir` from the call that started the turn. A marker can no longer be stale: turn N+1 writes into its own directory, so nothing from turn N is in it. ### Completion notifications (Claude Code channels) When a turn's process exits the harness pushes a `notifications/claude/channel` event, which Claude Code turns into a `` block in the lead agent's next turn: ```txt content: hyprpilot harness session 4670d5aa… finished (exit 0). Read its output with session_read. meta: { session: "4670d5aa…", exit_code: "0" } ``` On by default. It is safe to leave on — a client that has not registered the channel drops the notification silently, and unknown capabilities are ignored per the MCP spec, so nothing errors anywhere. The knob exists for **noise**: a session is `exited` after every *turn*, so a ten-turn conversation emits ten events. ```yaml mcp: harness: notify_on_complete: false ``` Resolved by the **launcher**, from the profile it picked, and passed to the sidecar as a flag — the same way `max_sessions` arrives. A sidecar cannot work out which profile spawned it, so it cannot read this from config itself. Two things worth knowing: * **Registering the channel is the client's job, not hyprpilot's.** Claude Code only listens for channels it was launched with; that is your own launch configuration. hyprpilot declares the capability and pushes the event — where channels are unavailable, the push is dropped. * **The content is a fixed template.** Transcript bytes and agent output are never interpolated into it — that would let a spawned agent write into its parent's context through a path the parent never called. Everything variable rides `meta`, whose keys must be `[A-Za-z0-9_]` (a hyphen is silently dropped, which is why it is `exit_code`). ### `spawn` / `session_send` parameters The two tools share one parameter set: | Parameter | Type | Default | What it does | | ----------------- | ---------------- | ------------- | ------------------------------------------------------------------------------------------------------------- | | `prompt` | string | — | The instruction to send. Mutually exclusive with `file`. | | `file` | string | — | Path to a file whose contents become the prompt (`~` / `$VAR` expanded). Mutually exclusive with `prompt`. | | `cwd` | string | profile's cwd | Working directory for the agent. | | `mode` | string | — | Vendor mode override (e.g. claude's `plan`). Overrides the profile. | | `with_config` | array of objects | `[]` | Ad-hoc profile overlays. **Restricted to `model`, `effort` and `mode`** — see below. | | `args` | string\[] | `[]` | Extra arguments forwarded verbatim to the vendor CLI — the tool equivalent of the CLI's trailing `-- `. | | `wait` | bool | `false` | Block until the turn finishes. Left off, the call returns as soon as the turn starts — poll `session_status`. | | `timeout_seconds` | integer | `300` | Seconds to wait when `wait` is true. On timeout the agent keeps running; the result reports status `running`. | Exactly one of `prompt` / `file` is required on both — the same mutual exclusion the CLI's `-p`/`-f` enforce. `spawn` additionally requires `profile` (an id from `list_profiles`). `session_send` additionally requires `session` (a handle from `spawn` or `session_list`) and has **no** `profile` parameter — the profile is inherited from the original spawn, so a conversation can't switch profiles mid-stream. `session_send` **replays the original launch** and does not let you change it. Only `prompt` / `file`, `mode`, `wait` and `timeout_seconds` are per-turn; `cwd`, `args` and `with_config` are inherited from the `spawn` and are **rejected** if passed — start a new session to launch differently. `mode` is the exception because a per-turn permission change is a real workflow (`mode: plan` for a read-only follow-up) and it does not affect how the vendor looks the conversation up. How a conversation was launched is part of its **identity**, not a per-turn option — re-deriving a follow-up turn from defaults launched it differently from the first, silently. The visible failure was `cwd`: claude keys its conversation store by project directory, so a resume from elsewhere came back with a bare `No conversation found with session ID: …` for a perfectly healthy session, because it was looked up in the wrong place. A dropped `mode` or `args` is quieter and worse — it changes the agent's permissions or flags mid-conversation without saying anything. ::: warning `with_config` is restricted to `model`, `effort` and `mode` Unlike the CLI's `--with-config`, the harness accepts only those three keys — an allow-list, not a block-list. A profile overlay can otherwise reach `command`, `args` and `env` (which replace the binary outright), `mcps` (whose inline `mcp_servers` entries carry their own `command`/`args`, which the vendor then spawns), `$deleteFromPrimitiveList/` directives (which mutate a field without ever naming it, e.g. stripping a profile's `--sandbox`), and `system_prompt` (which reads an arbitrary file into the agent's context). Any of those turns `spawn` into arbitrary command execution as the sidecar's user. Enumerating the ways *in* is a losing game against a config tree that grows; enumerating what's allowed is not. To run something else, add a profile for it in the hyprpilot config — that is the captain's decision to make, not the calling agent's. ::: ### `session_read` parameters | Parameter | Type | Default | What it does | | ----------------- | ------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `session` | string | — | Required. Handle from `spawn` or `session_list`. | | `tail` | integer | `200` | Trailing lines to return when `cursor` is omitted. | | `cursor` | string | — | Opaque pagination cursor — pass a previous result's `nextCursor` verbatim to continue where it stopped. | | `wait` | bool | `false` | Follow the session live from `cursor` instead of returning immediately — the same knob, with the same meaning, as `spawn`'s `wait`. | | `timeout_seconds` | integer | — | Caps a `wait` follow, in seconds. Inert without `wait: true`. Omit to follow until the agent finishes or you cancel. | **Pagination follows the MCP idiom.** `cursor` in, `nextCursor` out, opaque both ways — pass one back verbatim, never parse or construct one. **An absent `nextCursor` means the session is finished and you have all of it**; a running session always returns one, so a poller never loses its place. An unrecognised cursor is an error rather than a silent reset. There is no `truncated` flag: the cursor's presence is the signal. A follow streams each new chunk as an MCP `notifications/progress` message when the caller's request carries a `progressToken`; without one it degrades to a plain long poll and the caller still gets everything in the final result. It ends on whichever comes first: the agent finishing, the caller cancelling the request, or `timeout_seconds` elapsing — there's no other server-side time limit. ## `session_send` semantics `session_send` doesn't require the target session to still be alive — it inspects the handle's status and does whatever's needed: * **Refused** if the session is still `running` — no vendor supports two concurrent turns on one conversation. Wait for it (poll `session_status`) or `session_kill` it first. * **Refused** if it cannot be resumed, because the vendor never reported a session id for it — its first turn likely failed before the agent even started; check `session_read`. * Otherwise it **resumes**: the vendor's own session store continues the conversation in a new process, and the result's `delivery` field reports `"resumed"`. **The handle does not change.** It stays valid for the whole conversation however many turns you send, and each turn writes its own files — so an N-turn conversation costs one session, not N, and every turn's output stays separately addressable. A `session_read` cursor names its turn, so a resume reads the file it was taken from. ## Session lifetime Every session is a **direct child** of `hyprpilot mcp harness`, not a daemon of its own: a `tokio::process::Child` waited on in-process, with its transcript streamed into a per-session, owner-only (`0700`) temp directory. That has one hard consequence — **sessions die with the server, and their transcripts die with them.** Restarting the sidecar, for any reason (the vendor restarting it, the host process exiting, a crash), doesn't preserve a single running or finished session: there's no persistence and no state across launches. Treat a `spawn`/`session_send` chain as living only as long as the MCP connection that started it; if a result needs to survive that boundary, capture it before the connection ends. On a graceful transport close, or on `SIGTERM`/`SIGHUP`, the server kills every live session (SIGTERM the process group, a grace period, then SIGKILL if it didn't listen) and removes its temp directory before exiting — a clean shutdown never leaves an orphan behind. ## Orphan prevention A crashed or forcibly-killed sidecar is a different story from a clean shutdown, so orphan prevention is layered — only the last layer is a guarantee: 1. **Graceful shutdown** — the path above. Userspace, so it only runs if the process gets a chance to run destructors at all. 2. **tokio's drop guard** (`kill_on_drop`) — without it, tokio's default behavior for a dropped, still-running child is to push it onto a global orphan queue rather than kill it, which is precisely the failure this exists to prevent. Still userspace. 3. **`PR_SET_PDEATHSIG`** (Linux only) — the kernel kills the child when the sidecar dies, *however* it dies. This is the only layer that survives a `SIGKILL` of the sidecar, or the release build's `panic = "abort"`, both of which run no destructor at all. Each session also runs in its **own process group**, so a kill from `session_kill` or graceful shutdown signals the whole group — reaching everything the vendor itself spawned (its own MCP subprocesses, tool calls), not just the direct child. **PDEATHSIG is the exception, and it matters.** It signals only the *direct* child, and is cleared across that child's own forks. So in exactly the case layer 3 exists for — the sidecar `SIGKILL`ed or aborted, with no chance to signal anything — the vendor dies but **its grandchildren can survive** until the next harness sidecar sweeps them. Layers 1 and 2 cover the group; layer 3 covers only the child. Because PDEATHSIG is Linux-only, the guarantee degrades elsewhere to the first two layers, both of which a `SIGKILL` of the sidecar defeats. A **startup sweep** (run once by `mcp harness` before it starts serving) covers what none of the three layers can: a machine crash, or the surviving grandchildren described above. It scans the temp directory for leftover session directories, kills any process group still alive (recorded in a crash-recovery breadcrumb written at spawn time), and removes the directory — logging a warning whenever it reclaims something, since a non-empty sweep means a previous sidecar died badly. The sweep only reclaims sessions whose **owning sidecar is gone**. Each breadcrumb records the pid of the sidecar that created it, and the sweep skips any directory whose owner is still alive — or whose ownership it cannot establish. Running two harness sidecars at once is an ordinary setup, and without that check the newcomer's "recovery" would kill the other's live agents and delete transcripts still being written. ## Limits | Limit | Value | Enforced by | | ------------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | Concurrent running sessions | none (`max_live_sessions: 0`) | Off by default. Set a number and `spawn` is refused past it; `session_kill` a finished or runaway session to free a slot. | | Spawn nesting depth | 1 (`max_depth`) | `HYPRPILOT_SPAWN_DEPTH` env, stamped on every launch. At the cap no harness is injected, and `spawn` is refused. | | Transcript read per call | 60,000 bytes | Caps `session_read` and an inline `spawn`/`session_send` result. | | Default tail | 200 lines | `session_read`'s default when `cursor` is omitted. | | Default turn timeout | 300 seconds | How long `spawn`/`session_send` block when asked to `wait: true`, before reporting status `running`. Inert by default. | | Retained **finished** sessions | 64 (`max_sessions`) | Past this, the oldest are evicted (with their transcripts) and logged. A running session is never evicted and never counted. `0` retains them all. | Only distinct `spawn`s grow the table — a conversation reuses its session however many turns it runs — so the retention limit bounds a long-lived server's memory and temp directories without a tool you have to remember to call. Raise `max_sessions` on a busy gateway that wants deeper history; lower it where temp space is tight. **Running sessions are outside the retention count, not merely spared by it.** A running session holds a live model connection rather than history, so counting one would spend the retention budget on work still in flight: with the ceiling off, enough concurrent agents would evict every transcript the cap exists to keep, and the busier the sidecar the less history survived it. `session_list` reflects the same priority — running sessions lead, then the most recent turn first — so what is happening right now is never buried among retained transcripts. **The concurrency ceiling is off by default.** How many agents are worth running at once is a property of your machine and your work, so hyprpilot does not guess: `spawn` refuses nothing until you set `mcp.harness.max_live_sessions`. Set it on a shared or resource-tight host, where a profile's `command` being an arbitrary binary makes an unbounded fan-out something the host pays for. Leave it where fanning out wide is the point. To free a session earlier than the limit would, call `session_kill` on it: on a finished session that reaps it and its transcript immediately. The default depth ceiling of 1 means **the lead delegates and the delegate works**. It is enforced twice from one number, `mcp.harness.max_depth`: * **At launch**, a session at or past the cap gets **no harness server injected at all**. This is absolute — no `enabled: true` re-opens it, because such a server could only ever refuse `spawn`. Without it a delegate paid for a long-lived sidecar and seven tools that existed to error. * **At the tool**, a sidecar at or past the cap refuses `spawn`, with a message naming the knob that changes it. Raise `max_depth` and both move together: at `2` your delegates get a harness and may delegate once more, and theirs may not. **The raise has to reach the delegate's own block**, because the gate reads the cap from whichever block it is deciding on. An unscoped patch does that; so does `mcp.harness.mcp.harness.max_depth` on the lead. A raise scoped to the lead alone (`$match: { profile: gateway }`) leaves workers at the seeded `1`, so they still get no harness. The same rule read the other way: a worker profile carrying its own raised `max_depth` may delegate regardless of what the lead's cap says. That is a resource decision, not a security one. A `max_live_sessions` ceiling — where you set one at all — is enforced by each sidecar over *its own* table and cannot see any other's, so allowing one more level lets N delegates each run N sessions: N² processes no single ceiling catches, none of them visible to the lead that started the tree. Keeping the default at 1 makes every session a direct child of the caller who can list and kill it, so one ceiling on that caller still describes the whole fan-out. Since a profile's `command` can be any binary, a tree that spawns without limit can exhaust the host — raise `max_depth` and a concurrency ceiling stops being a number you can reason about. There is no upper bound on `max_depth`; the trade is yours. ## What your delegates can reach `mcp.harness.mcp` is an `mcp` block applied to every delegate this harness spawns, folded **per key** over whatever the delegate profile itself resolved — a key you set wins, a key you omit inherits. ```yaml patches: - $match: profile: gateway mcp: harness: enabled: true mcp: serve: enabled: false ``` It answers a different question from `includeProfiles`: that one is *which* profiles you may launch, this is *what those agents can reach* once running. `harness.enabled: true` in here does not hand a delegate a harness — the depth gate runs first and never consults `enabled`. `harness.max_depth` in here does, because the gate reads the cap from the block it is deciding on and the overlay is part of that block. Use it to grant one delegation an extra level without raising the cap globally. Like `max_sessions` and `notify_on_complete`, the launcher resolves it from the picked profile and passes it to the sidecar as `--delegate-mcp=`; a sidecar cannot work out which profile spawned it. A malformed block **fails the sidecar at startup** rather than degrading to "no overlay" — this block is what narrows a delegate's reach, so silently dropping it would widen it. ## Example config Two independent switches, and you need **both**: 1. `mcp.harness.enabled` — whether the *server* is injected at all, for the profile doing the orchestrating. 2. `profiles.harness.enabled` — which profiles that server is allowed to *drive*. ```yaml patches: # The gateway profile gets the harness server injected. - $match: profile: gateway mcp: harness: enabled: true max_sessions: 128 # …and these are the profiles it may launch. - $match: profile: 'worker/*' harness: enabled: true profiles: - id: gateway agent: claude-code - id: worker/engineer agent: claude-code - id: deploy # neither switch — invisible to the harness agent: claude-code ``` Setting only the first gives an agent the tools and an **empty** `list_profiles`; setting only the second nominates profiles nothing can reach. A profile's own `mcp` block works in place of the first patch, but it wholesale-replaces the global one, so you would have to restate `skills` alongside it. `mcp.harness.includeProfiles` / `excludeProfiles` add a third, optional narrowing on top of switch 1 — see [Scoping delegation per launch](#scoping-delegation-per-launch). To run it by hand (debugging, or a gateway that manages its own MCP config), the subcommand takes no catalogue: ```sh hyprpilot mcp harness --max-sessions 64 ``` The launcher resolves the picked profile's scope and passes it down as repeated flags — a sidecar cannot work out which profile spawned it, so it cannot read this from config itself: ```sh hyprpilot mcp harness --include-profile 'personal/*' --exclude-profile 'personal/codex/*' hyprpilot mcp harness --no-delegates # includeProfiles: [] hyprpilot mcp harness --max-depth 2 # mcp.harness.max_depth hyprpilot mcp harness --delegate-mcp '{"serve":{"enabled":false}}' ``` `--no-delegates` exists because zero `--include-profile` occurrences is exactly what an unset list looks like on the wire, and unset means unrestricted — the opposite of an empty list. --- --- url: 'https://hyprpilot.kilic.dev/repository/foreword.md' --- # {{ $frontmatter.title }} Contributions are very welcome in this repository, and everything is open to discussion — features, requests, bugs, and even help requests. Nothing will mostly go stale unless there are technical limitations or blockers, but it might take time due to **life**. ## What this is Hyprpilot is a personal tool that grew documentation: a config-driven, fire-and-exec launcher for terminal coding agents. It scratches a specific itch — launching the same agents with the same profiles all day — and it is shared in the hope the itch is not unique. ## Contributions are welcome Contributions from all skill levels are welcome. You can always propose a new feature or report a bug, and improving documentation or tests counts just as much. There are no strict guidelines beyond the [general ones](./contributions) — following them keeps things consistent. If you are unsure whether an idea fits, open a [Discussion](https://github.com/hyprpilot/hyprpilot/discussions) first — sketching in a thread is cheaper than sketching in a pull request. ## Getting around * [Development](./development) — toolchain, tasks, and the module map. * [Contributions](./contributions) — how to report, propose, and send changes. * [Release](./release) — how versions get cut and shipped to the AUR. * [LLM Docs](./llms) — the LLM-friendly endpoints of this documentation. --- --- url: 'https://hyprpilot.kilic.dev/repository/development.md' --- # {{ $frontmatter.title }} Hyprpilot is a single Rust crate at the repo root — no frontend, no webview, no node runtime beyond the docs site. The toolchain is pinned through [`mise`](https://mise.jdx.dev), and [`task`](https://taskfile.dev) drives everything you'll typically run. ## Getting it running ```sh git clone https://github.com/hyprpilot/hyprpilot cd hyprpilot mise install task build ``` `mise install` drops the pinned toolchain: Rust (stable + `rustfmt` + `clippy`), `task`, `cargo-nextest`, plus node + pnpm for the `docs/` VitePress site — the only Node consumer in the repo. ## Tasks | Task | Purpose | | ----------------------------------------------- | --------------------------------------------------------------------- | | `task install` | `cargo fetch` + `pnpm install`. | | `task build` | Debug build of the launcher. | | `task release` | Release build. | | `task test` | Rust test suite via `cargo nextest`. | | `task lint` | `cargo fmt --check` + `cargo clippy -D warnings`, plus the docs lint. | | `task format` | `cargo fmt --all`, plus the docs formatter. | | `task run -- ` | `cargo run` with launcher args. | | `task docs:dev` / `docs:build` / `docs:preview` | VitePress docs site. | The pre-push bar is `task build && task lint && task test` — all green. CI runs lint, test, and build as separate jobs. ## Where things live The crate is a single package at the repo root (`Cargo.toml` + `src/`). Key modules: * `src/main.rs` — the `clap`-derive CLI. Bare invocation launches; `mcp` / `profiles` are the only subcommands. * `src/config/` — layered config load, merge, validation, `[[agents]]` / `[[profiles]]`, patches, and the compiled `defaults.toml`. * `src/resolve/` — the pure `Config` → resolution core (profile pick, patch folding, per-launch MCP + skills registries). * `src/spawn/` — profile launch: per-vendor native-flag projection, the interactive picker, the multiplexer rename, and the final `exec()`. * `src/mcp/` — the MCP catalogue plus the three in-tree servers (`hyprpilot mcp serve` / `skills` / `harness`). * `src/mcp/skills/` — the `SKILL.md` loader and registry, under `mcp/` because it exists for the skills server. ## Found a rough edge? Open an issue, send a PR, or drop a thought in [Discussions](https://github.com/hyprpilot/hyprpilot/discussions). Small, focused changes are the easiest to review and land — see [Contributions](./contributions). --- --- url: 'https://hyprpilot.kilic.dev/repository/contributions.md' --- # {{ $frontmatter.title }} Issues, ideas, and pull requests are all welcome. ## Found a bug? Open a [GitHub Issue](https://github.com/hyprpilot/hyprpilot/issues) and include enough that someone else can reproduce it: what you expected, what actually happened, and a minimal config + profile that triggers it. A stderr snippet from a `--log-level debug` run helps a lot too. ## Have an idea? [Discussions](https://github.com/hyprpilot/hyprpilot/discussions) is the right place for "would this fit?" or "how would you do this?" — anything where you're sketching rather than reporting. If the conversation lands on something concrete, we can move it to an issue from there. ## Sending a pull request Fork, branch, push, open a PR against `main`. That's it. Smaller PRs are easier to review and land — one logical change per PR if you can swing it. Don't worry about getting the commit history perfect; we can tidy it on the way in. Commit messages follow [conventional commits](https://www.conventionalcommits.org/) — they drive the [release automation](./release), so a `feat:` / `fix:` prefix is what turns your change into a version bump. If you're not sure your idea will be accepted, open a Discussion or Issue first to sanity-check the direction. Saves everyone time. ## Building from source See [Development](./development) for the toolchain and `task` targets. The pre-push bar is `task build && task lint && task test` — all green. --- --- url: 'https://hyprpilot.kilic.dev/repository/release.md' --- # {{ $frontmatter.title }} Releases are fully automated: conventional commits drive [release-please](https://github.com/googleapis/release-please), and every published release rolls out to the [Arch User Repository](https://aur.archlinux.org) on its own. ## Versioning Commit types map to version bumps through the standard Angular table: | Commit | Bump | | ------------------------------------------ | ------------------------------ | | `feat: …` | minor | | `fix:` / `perf:` / `refactor:` / `docs: …` | patch | | `chore:` / `test:` / `ci: …` | none (hidden in the changelog) | | `!` suffix or `BREAKING CHANGE:` footer | major | release-please maintains a rolling release PR on `main`; merging it tags the version, writes the changelog, and publishes the GitHub Release. ## The AUR pipeline Publishing a release triggers the release workflow, which builds a Linux x86\_64 tarball and pushes the updated **`hyprpilot-bin`** package to the AUR. The **`hyprpilot-git`** PKGBUILD is pushed separately whenever the PKGBUILD itself changes — a VCS package rebuilds from the latest `main` on your machine, so it needs no per-release update. * **`hyprpilot-bin`** — prebuilt binary, fastest to install, tracks tagged releases. * **`hyprpilot-git`** — builds from the latest `main` with `cargo`, for the bleeding edge. You don't need to do anything to get a new version; `yay -S hyprpilot-bin` (or your AUR helper of choice) picks it up the next time you upgrade. ## What about other distros? Right now hyprpilot only publishes for Arch and Arch-likes. The binary itself is a plain Rust build with no webkit / gtk / node dependency, so building from source on other distros is straightforward — see [Development](./development) for the toolchain. If you'd like to maintain a package for another distro, that'd be very welcome — open an issue and we'll help where we can. ## Something broken in a release? If a published version misbehaves on your machine — the picker won't open, a launch fails, the wrong vendor flags get projected, anything — please [open an issue](https://github.com/hyprpilot/hyprpilot/issues) with your distro, the vendor CLI + version, and the relevant log snippet (`--log-level debug`). The faster we hear about it, the faster the next release fixes it. --- --- url: 'https://hyprpilot.kilic.dev/repository/llms.md' --- # {{ $frontmatter.title }} The documentation site is published in an LLM-friendly form so AI assistants and agents can consume it directly. It is generated at build time by [`vitepress-plugin-llms`](https://github.com/okineadev/vitepress-plugin-llms), following the [llms.txt](https://llmstxt.org) convention. ## Endpoints * **[`/llms.txt`](https://hyprpilot.kilic.dev/llms.txt)** — an index of the documentation: the site description plus a linked table of contents. * **[`/llms-full.txt`](https://hyprpilot.kilic.dev/llms-full.txt)** — the entire documentation concatenated into a single Markdown file, to drop into a context window in one shot. * **Per-page Markdown** — every page is also emitted as raw Markdown at its path with a `.md` suffix (e.g. `/config/profiles.md`), so an agent can fetch just the page it needs. ## Using it with an agent Point your agent or LLM at whichever endpoint fits the task: * For a broad overview, or to let the model discover the right page, give it `https://hyprpilot.kilic.dev/llms.txt`. * To load the whole documentation at once, use `https://hyprpilot.kilic.dev/llms-full.txt`. * To answer a question about a single topic, fetch that page's Markdown — e.g. `https://hyprpilot.kilic.dev/runtime/skills.md`. These are plain Markdown, so any tool that can fetch a URL — a coding agent, a retrieval pipeline, or a chat with browsing — can read them without scraping the rendered HTML.