From 1c714055579c0626a1a6baf44f5117250fe34238 Mon Sep 17 00:00:00 2001 From: Fullstop000 Date: Tue, 7 Jul 2026 20:13:01 +0800 Subject: [PATCH 1/4] docs: sync stale references with the current codebase CLAUDE.md, the top-level README, and docs/ had drifted from the code. Every change below was verified against source + CHANGELOG. CLAUDE.md: - Drop the removed `--tui` flag (v0.15.0); no-arg is the TUI. - Fix the TUI module path: `src/tui.rs` -> `src/console/` (colors in `src/console/colors.rs`). - Color-coded *borders* -> *bullets* for tool-call status. - Reframe "single binary" for the Ink-default / ratatui-fallback split (Ink became default in v0.40.0). - Document subcommands (mcp/upgrade/sessions) and flags (-r/--afk/-v). - Add an Architecture section mapping the ignis/src/ module layout. docs/usage/hooks.md: - Document all 4 hook events; PreToolUse/PostToolUse were missing (added in v0.41.0). Add their envelope fields, matcher, and block/rewrite semantics. - Landlock ABI V1 / Linux 5.13+ -> ABI V2 / Linux 5.19+ (v0.41.3). docs/configure/permissions.md: - The bash sandbox shipped (v0.41-v0.43). Document it (opt-in, /settings toggle, write/read confinement, sandbox_{read,write}_paths) and drop it from the roadmap. docs/configure/telemetry.md: - Remove the "Anthropic emits no token usage" limitation (fixed in #175; anthropic.rs parses message_delta into a Usage delta). docs/usage/commands.md: - Add /connect, /hooks, /settings (present in slash.rs SLASH_COMMANDS). - /telemetry now toggles, not read-only. - Ctrl+D exits on a double press (Ink + native TUI). docs/configure/mcp.md: - Per-tool permission rules ARE supported (exact mcp__server__tool names); only per-server globs are not (rule.rs matches_liberal). docs/README.md + README.md: - Index the hooks page (it was orphaned). README: Ctrl+D note and drop the stale "experimental" label on the now-default Ink frontend. --- CLAUDE.md | 37 ++++++++++++----- README.md | 6 +-- docs/README.md | 10 +++-- docs/configure/mcp.md | 9 +++-- docs/configure/permissions.md | 31 +++++++++++++-- docs/configure/telemetry.md | 4 -- docs/usage/commands.md | 47 ++++++++++++++++++++-- docs/usage/hooks.md | 75 ++++++++++++++++++++++++++++++----- 8 files changed, 180 insertions(+), 39 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e618872f..53b48c57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,10 +4,11 @@ This guide contains commands, patterns, and style rules for developers and AI as ## Build and Run Commands -* **Build all workspace crates:** `cargo build` -* **Run interactive TUI (default):** `cargo run` -* **Run interactive TUI (explicit):** `cargo run -- --tui` -* **Run one-shot CLI:** `cargo run -- ` +* **Build all workspace crates:** `cargo build` (workspace = `ignis` + `ignis-macros`; `ignis-tui/` is a separate Node/Ink project, not a Cargo crate). +* **Run interactive TUI (default):** `cargo run` — no-arg launches the TUI. The default UI is the **Ink frontend** (`ignis-tui/`, requires Node ≥18); `IGNIS_FRONTEND=native` forces the built-in `ratatui` TUI, which is also the automatic fallback when Node is missing. (The `--tui` flag was removed in v0.15.0 — no-arg *is* the TUI.) +* **Run one-shot CLI:** `cargo run -- ` — streams a single turn to stdout and exits. +* **Subcommands:** `ignis mcp`, `ignis upgrade` (alias `update`), `ignis sessions`. +* **Other flags:** `-r, --resume [ID]`, `--afk` (fully unattended), `-v, --version`. * **Clippy/Lints check:** `cargo clippy --workspace --all-targets -- -D warnings` * **Rust Formatter:** `cargo fmt --all -- --check` @@ -16,6 +17,24 @@ This guide contains commands, patterns, and style rules for developers and AI as * **Run all unit tests:** `cargo test --workspace` * **Run specific test file or pattern:** `cargo test ` +## Architecture + +The Rust core lives in `ignis/src/` as directory modules sharing one `Session`-centric loop — entry/UI at the edges, engine in the middle: + +* `agent/` — stateless turn-execution engine; runs the tool-dispatch loop and streams `AgentEvent`s (message deltas, tool start/end, turn end). +* `session/` — core conversational model; owns message `history` + persistence and wraps an `Agent`, advancing the conversation via `Session::prompt` / `compact`. +* `llm/` — LLM domain: model catalog, provider-brand declarations, and wire protocols (Anthropic / OpenAI-compatible). +* `tools/` — built-in tool registry (`bash`, `read_file`, `edit_file`, `grep`, `glob`, `agent` sub-agent, `ask_user`, `todo_write`, web, worktree, …) plus the `#[tool]` trait machinery. +* `permissions/` — the tool-call gate; a single 3-state `Mode` (`Off` / `HandsFree` / `FullyUnattended`) + rule set, enforced via a `PermissionChecker` `ToolHooks` impl. +* `hooks/` — external subprocess hooks on `UserPromptSubmit`, `PreToolUse`/`PostToolUse`, and `AssistantMessageRender`; failures degrade to "use original + warn" and never kill a turn. +* `sandbox/` — policy-free process-confinement primitive (Landlock on Linux, Seatbelt on macOS) shared by hook and bash subprocesses. +* `mcp/` — Model Context Protocol client; spawns configured stdio/HTTP MCP servers and exposes their tools as `mcp____`. +* `skills/` — user `SKILL.md` instruction sets discovered from disk, advertised to the model, loaded on demand, toggleable at runtime. +* `console/` — the TUI layer: `runner` (event loop + ~30fps frame tick), `app` (state), `render/` (draw), `keys`/`slash`/`composer`/`pickers`, and `frontend/` (the headless `--engine` NDJSON protocol the Ink host drives). +* `cli/` — the clap CLI surface (flags + `mcp`/`upgrade`/`sessions` subcommands) and the Ink-frontend resolver. + +Cross-cutting top-level files: `main.rs` (routing — `--engine` headless vs TUI vs one-shot), `config.rs` (TOML config + provider/model resolution), `state.rs` (persisted `state.json`: mode, grants, disabled skills/MCP), `telemetry.rs` (opt-in OpenTelemetry). + --- ## Coding Guidelines & Style Rules @@ -27,11 +46,11 @@ This guide contains commands, patterns, and style rules for developers and AI as * Keep changes tightly scoped to the current active goal. ### R2. TUI Design -* The primary UI is a **native terminal TUI** built with `ratatui` + `crossterm`. -* Keep the TUI responsive by processing `AgentEvent` updates at ~30fps. -* Use the established dark color palette (defined in `src/tui.rs`). -* Tool call blocks use color-coded borders: yellow=pending, green=success, red=error. -* Ignis ships as a **single binary** — no external runtime dependencies. +* Two frontends share one Rust core: the **Ink frontend** (`ignis-tui/`, Node/React-Ink — default when Node ≥18 is present, since v0.40.0) and the **built-in `ratatui` TUI** (`crossterm` backend, fallback). The Ink host owns the terminal and spawns the Rust binary as a headless `--engine` over an NDJSON stdin/stdout protocol; `IGNIS_FRONTEND=native` forces the built-in. +* The built-in TUI renders at a ~30fps frame tick (`FRAME = 33ms` in `console/runner.rs`); `AgentEvent`s stream in between frames and are coalesced into the next draw. +* Use the established dark color palette (Catppuccin Mocha, defined in `src/console/colors.rs`). +* Tool call blocks encode status via a color-coded **bullet** (`●`): yellow=pending, green=success, red=error (`console/render/tool_block.rs`). +* The Rust core ships as a **single binary**; the default Ink frontend additionally requires Node ≥18 (the `ratatui` TUI has no external runtime deps). ### R3. Quality & Warning Gate * Maintain **zero compiler warnings and clippy errors** in the Rust crates. diff --git a/README.md b/README.md index 2ece0cdf..2a8a9e88 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ ignis upgrade --version v0.14.1 # pin to a specific tag git clone https://github.com/Fullstop000/ignis.git cd ignis && cargo build --release # → target/release/ignis -# Optional: the experimental Ink frontend (Node required). Built from a source +# Optional: the Ink frontend (Node required). Built from a source # checkout, `ignis` launches it by default; install its deps once first. ( cd ignis-tui && npm install ) ``` @@ -146,14 +146,14 @@ the active selection at runtime, saving it to `~/.ignis/state.json` — your | `ignis upgrade` | Update to the latest release | | `ignis --help` | Full flag and subcommand list | -In the TUI: `Enter` sends, `↑/↓` walk history, `Ctrl+D` exits. Output renders +In the TUI: `Enter` sends, `↑/↓` walk history, `Ctrl+D` twice exits. Output renders inline in the normal buffer, so scroll with your terminal/tmux as usual. Type `/` for slash-command suggestions — see [`docs/usage/commands.md`](docs/usage/commands.md) for the full reference. ## Docs -Deep references live in [`docs/`](docs/README.md) — commands, permissions, +Deep references live in [`docs/`](docs/README.md) — commands, hooks, permissions, skills, MCP servers, telemetry. ## Development diff --git a/docs/README.md b/docs/README.md index 7b78f8fb..84c293c0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,9 +9,13 @@ feature list — these pages go deeper. How to drive ignis day to day. - [Commands](usage/commands.md) — every built-in TUI slash command (`/sessions`, - `/clear`, `/compact`, `/copy`, `/model`, `/skills`, `/mcp`, `/afk`, - `/telemetry`) plus the `/` force-load form, with a global - keybindings table. + `/clear`, `/compact`, `/copy`, `/connect`, `/model`, `/skills`, `/mcp`, `/afk`, + `/hooks`, `/settings`, `/telemetry`) plus the `/` force-load form, + with a global keybindings table. +- [Hooks](usage/hooks.md) — external subprocess hooks on the + `UserPromptSubmit`, `PreToolUse`/`PostToolUse`, and `AssistantMessageRender` + lifecycle events: envelope, exit codes, the `~/.ignis/hooks.json` declaration, + and the per-hook filesystem sandbox. ## Configure diff --git a/docs/configure/mcp.md b/docs/configure/mcp.md index cc9b4c45..84752a8d 100644 --- a/docs/configure/mcp.md +++ b/docs/configure/mcp.md @@ -159,9 +159,12 @@ predictable: (or `bearer_token_env_var = "…"` in TOML). For stdio, pass credentials via `-e KEY=VALUE` or the server's own config file. - **MCP resources and prompts** — only `tools/*` surfaces are wired. -- **Per-tool permission rules** — the existing - [permissions](permissions.md) system gates the wrappers as a group; per-tool - allow/deny is not yet supported. +- **Per-server glob permission rules** — the existing + [permissions](permissions.md) system gates each MCP tool by its full + `mcp____` name, so you can `allow`/`ask`/`deny` an individual + tool (e.g. `deny = ["mcp__github__create_issue"]`). What's *not* supported is + globbing across a server's tools (`mcp__github__*`) — list each tool by name, + or gate the bare name. - **`${VAR}` interpolation in `headers`** — secrets belong in `bearer_token_env_var`; literal headers stay literal. - **Mid-session reconnect** on a hard transport drop. rmcp handles diff --git a/docs/configure/permissions.md b/docs/configure/permissions.md index 5d3563c7..8e2780a1 100644 --- a/docs/configure/permissions.md +++ b/docs/configure/permissions.md @@ -162,7 +162,34 @@ unattended they hard `Deny`. The floor is intentionally small, covers catastrophic-and-easily- recognized cases, and doesn't try to reason about every destructive -command. Sandbox-level enforcement (Linux Landlock) is on the roadmap. +command. + +## Bash sandbox + +For auto-approved `bash` in the unattended modes (hands-free / AFK), +ignis can additionally confine each spawned command with a filesystem +sandbox — a defense-in-depth layer *below* the permission gate. It is +**opt-in and off by default** (so credentialed commands like `git push` +work out of the box); turn it on in `/settings` → *Sandbox auto-run bash*. +The choice persists in `~/.ignis/state.json`. + +When on, the sandbox confines **writes** to the project directory, the +temp dirs, `/dev/null`, and any configured `sandbox_write_paths`. On +**Linux** (Landlock, ABI V2) it also narrows **reads** to system roots, +the project, temp, and the Rust toolchain caches (`~/.cargo`, +`~/.rustup`), so `$HOME` credential dirs (`~/.ssh`, `~/.aws`, `~/.gnupg`, +`~/.ignis`) stay unreadable — extend reads with `sandbox_read_paths`. On +**macOS** (Seatbelt) the read narrowing isn't implemented; writes are +still confined. + +```toml +[permissions] +sandbox_write_paths = ["~/projects/shared", "/var/cache/myapp"] +sandbox_read_paths = ["~/.npm", "/opt/sdk"] +``` + +The sandbox only engages for auto-run (unattended) `bash` — under `Off`, +or with the toggle off, bash runs unsandboxed. ## Roadmap @@ -176,7 +203,5 @@ Not yet shipped: - `plan` mode — read-only exploration with no edits. - **Multi-scope rule layering** — managed > project > user precedence for the rule grammar (today there's one `config.toml`). -- **OS-level sandboxing** — Linux Landlock filesystem restrictions for - bash calls, as a defense-in-depth layer below the permission gate. Anything not listed above is not on the near-term roadmap. diff --git a/docs/configure/telemetry.md b/docs/configure/telemetry.md index ee571ffe..82aac1f5 100644 --- a/docs/configure/telemetry.md +++ b/docs/configure/telemetry.md @@ -136,10 +136,6 @@ defense in depth. ## Known limitations (v1) -- **Anthropic provider does not emit token usage** at all today (separate - pre-existing gap in the provider's stream parser). Anthropic users will see - spans but zero token-usage metric points. Follow-up issue: - add `message_delta` event parsing to `provider/anthropic.rs`. - **No distributed tracing** into bash/MCP subprocesses (`traceparent` propagation). Deferred to v2; rare need for the current use cases. - **Cost is not computed inside ignis.** Compute it backend-side from diff --git a/docs/usage/commands.md b/docs/usage/commands.md index 9ef8dcd3..5ebc7b04 100644 --- a/docs/usage/commands.md +++ b/docs/usage/commands.md @@ -67,6 +67,19 @@ no native dependency. --- +### `/connect` + +Connect a provider and pick a default model. The picker walks through choosing +a provider brand, entering credentials, and selecting a model; the choices are +saved to your ignis config (`~/.ignis/config.toml`). It's also the landing +screen when no provider is configured yet. + +``` +/connect +``` + +--- + ### `/model` Open the model picker. Lists every model declared under each configured @@ -127,8 +140,8 @@ confirmation picker before flipping state. ### `/telemetry` -Print the current OpenTelemetry exporter status (endpoint, headers redacted, -sample run-time counters) as an assistant notice. Read-only. +Show the current OpenTelemetry exporter status (endpoint, headers redacted, +sample run-time counters) as an assistant notice, and toggle export on or off. ``` /telemetry @@ -138,6 +151,34 @@ See [configure/telemetry.md](../configure/telemetry.md) for setup. --- +### `/hooks` + +List the hook chains the running session actually uses (`/hooks` or +`/hooks list`), or re-read `~/.ignis/hooks.json` after editing it +(`/hooks reload`). One block per event, each entry showing the program path, +argv tail, and per-hook timeout. See [hooks](hooks.md) for the full protocol. + +``` +/hooks +/hooks reload +``` + +--- + +### `/settings` + +Toggle live session settings from a panel — the bash sandbox (confine +unattended `bash` to the project + temp, away from `$HOME` secrets; off by +default), auto-compaction, stripping reasoning from history, and which +statusline segments show (model / cwd / git branch / turns / tokens). Choices +persist in `~/.ignis/state.json`. + +``` +/settings +``` + +--- + ### `/` For any enabled skill, typing its name as a slash command force-loads the @@ -170,4 +211,4 @@ keys apply while the TUI input is active: | `Ctrl+W` | Delete previous word | | `Ctrl+S` | Steer the running turn (queue an instruction mid-stream) | | `Ctrl+C` | Cancel the running turn / clear the input | -| `Ctrl+D` | Exit ignis | +| `Ctrl+D` (twice) | Exit ignis — the first press prompts "Press Ctrl-D again to exit" | diff --git a/docs/usage/hooks.md b/docs/usage/hooks.md index 1a55bf0e..fe1ea86d 100644 --- a/docs/usage/hooks.md +++ b/docs/usage/hooks.md @@ -1,10 +1,16 @@ # Hooks Hooks let an external program subscribe to ignis lifecycle events and, -where the event permits it, rewrite the data flowing through. v1 ships -two events — `UserPromptSubmit` (mutates the prompt before model send) -and `AssistantMessageRender` (mutates the assistant's text before TUI -render). +where the event permits it, rewrite the data flowing through. Four events +ship today: + +- `UserPromptSubmit` — mutates the user prompt before it enters history. +- `AssistantMessageRender` — mutates the assistant's text before TUI render. +- `PreToolUse` — runs before a tool call (and before the permission gate); + can rewrite the tool's args or block the call. A tool-name `matcher` + filters which hooks run. +- `PostToolUse` — runs after a tool call (success or error); can rewrite the + result the model sees or just observe. Cannot block. `matcher` applies. > ## Hook sandbox (v2) > @@ -26,7 +32,7 @@ render). > the whole tree). > > The mechanism is per-platform: -> * **Linux** uses Landlock (ABI V1, Linux 5.13+). On older kernels +> * **Linux** uses Landlock (ABI V2, Linux 5.19+). On older kernels > a one-time `[warn] hook.sandbox: : Landlock unavailable > on this kernel; hook runs unconfined` notice fires per session. > * **macOS** uses Apple's `sandbox_init(3)` ("Seatbelt") with a @@ -73,6 +79,24 @@ the model's original output**, not the rewritten render — so prompt cache stays clean and replay is exact. The rewrite shows as a labelled `[hook rewrite]` block immediately below the model's original. +### `PreToolUse` + +Fires before a tool call runs — and before the permission gate, so a +rewrite is what both the gate and the tool see. Hooks may rewrite the +tool's args (`updatedInput`, parsed back to JSON; a malformed rewrite is +a soft failure that keeps the prior args) or block the call +(`continue: false` or exit 2). Only specs whose `matcher` matches the +tool name run; an absent `matcher` runs for every tool. Each hook +receives the previous hook's (possibly rewritten) args. + +### `PostToolUse` + +Fires after a tool call completes, whether it succeeded or errored. Hooks +may rewrite the result text the model sees (`updatedOutput`) or just +observe it (along with `isError`). It **cannot block** — the tool already +ran, so `continue: false` / exit 2 degrades to a warning and the current +result passes through. `matcher` applies; chaining as above. + ## Envelope ### stdin — JSON object @@ -88,7 +112,11 @@ labelled `[hook rewrite]` block immediately below the model's original. - `prompt` is present for `UserPromptSubmit`. - `content` is present for `AssistantMessageRender`. -- The other field is omitted. +- `toolName` and `toolInput` are present for `PreToolUse` / `PostToolUse` + (the tool name and its argument object). +- `toolResult` and `isError` are additionally present for `PostToolUse` + (the tool's result text and whether it errored). +- Fields not relevant to an event are omitted. ### stdout — JSON object (all fields optional) @@ -103,16 +131,20 @@ labelled `[hook rewrite]` block immediately below the model's original. } ``` -For `AssistantMessageRender`, the rewrite field is `updatedOutput`. -Absent rewrite field, or `continue: false`, means "no rewrite from this -hook" — but `continue: false` is also a block signal (see exit codes). +For `AssistantMessageRender` and `PostToolUse`, the rewrite field is +`updatedOutput`; for `UserPromptSubmit` and `PreToolUse` it is +`updatedInput`. An absent rewrite field means "no rewrite from this +hook". `continue: false` is also a block signal (see exit codes) — +honoured for `UserPromptSubmit` and `PreToolUse`, degraded to a soft +failure for `AssistantMessageRender` and `PostToolUse` (the message / +tool already ran, so it can't be unsent). ## Exit codes | Code | Behaviour | |---|---| | `0` | OK. stdout is parsed; absent/empty stdout = pass-through. | -| `2` | Block the chain. Honoured for `UserPromptSubmit` (turn does not send). Degraded to a soft failure for `AssistantMessageRender`. | +| `2` | Block the chain. Honoured for `UserPromptSubmit` (turn does not send) and `PreToolUse` (tool call does not run). Degraded to a soft failure for `AssistantMessageRender` and `PostToolUse` (already ran). | | anything else | Soft failure: original text kept; a `[warn]` line is committed to scrollback. | A hook that runs longer than its `timeout_ms` is sent `SIGTERM`, @@ -139,6 +171,20 @@ given one second to exit cleanly, and then `SIGKILL`'d. Outcome is "env": ["ANTHROPIC_API_KEY"], "timeout_ms": 30000 } + ], + "PreToolUse": [ + { + "command": "~/.ignis/hooks/redact.sh", + "matcher": "bash", + "timeout_ms": 10000 + } + ], + "PostToolUse": [ + { + "command": "~/.ignis/hooks/observe.sh", + "matcher": "mcp__*", + "timeout_ms": 10000 + } ] } } @@ -167,6 +213,9 @@ given one second to exit cleanly, and then `SIGKILL`'d. Outcome is legitimately need broader filesystem access. On platforms with no sandbox implementation the flag has no effect — the one-time `[warn] hook.sandbox` notice is your hint. +- `matcher` (PreToolUse / PostToolUse only) is a tool-name glob — `bash`, + `edit_*`, `mcp__*`, or `*`. Absent means the hook runs for every tool. + Ignored by the prompt/render events. - Each event takes a JSON array — multiple hooks chain left-to-right, each receiving the previous hook's output. - The file is loaded at session start. An absent file means no hooks @@ -182,12 +231,16 @@ per-hook timeout. The leftmost column is the hook's `display_name()` (its program file's stem, no directory or extension): ``` -[info] 3 hooks registered · /hooks reload to re-read · run unsandboxed; audit before installing: +[info] 5 hooks registered · /hooks reload to re-read · run unsandboxed; audit before installing: UserPromptSubmit (2): · translate-en ~/.ignis/hooks/translate-en/run.py (timeout 10000ms) · redact /opt/ignis/hooks/redact.sh --strict (timeout 30000ms) AssistantMessageRender (1): · translate-en ~/.ignis/hooks/translate-en/run.py (timeout 10000ms) + PreToolUse (1): + · redact ~/.ignis/hooks/redact.sh (timeout 10000ms) + PostToolUse (1): + · observe ~/.ignis/hooks/observe.sh (timeout 10000ms) ``` (The `translate-en` in the name column there assumes your program From edb92658aa8d4c0b8ffd312c8d0ff9700977b267 Mon Sep 17 00:00:00 2001 From: Fullstop000 Date: Tue, 7 Jul 2026 20:18:44 +0800 Subject: [PATCH 2/4] docs: mark the built-in ratatui TUI as deprecated The built-in ratatui TUI is deprecated and no longer actively developed; the Ink frontend is the only actively-maintained UI. IGNIS_FRONTEND=native still works and remains the automatic fallback when Node >=18 is missing. - CLAUDE.md: deprecate ratatui in Build/Run and R2 (TUI Design); reframe the "single binary" note. - README.md: deprecate ratatui in the Install paragraph, the TUI+CLI and Single-binary feature bullets. - CHANGELOG.md: record the deprecation under [Unreleased] -> Deprecated. --- CHANGELOG.md | 3 +++ CLAUDE.md | 6 +++--- README.md | 15 ++++++++------- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afb4ce38..1839d6a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Deprecated +- TUI — the built-in `ratatui` TUI (`IGNIS_FRONTEND=native`) is deprecated and no longer actively developed. It remains the automatic fallback when Node ≥18 is unavailable; the [Ink frontend](ignis-tui/README.md) is the only actively-maintained UI. + ## [0.45.2] - 2026-07-03 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 53b48c57..96299af5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ This guide contains commands, patterns, and style rules for developers and AI as ## Build and Run Commands * **Build all workspace crates:** `cargo build` (workspace = `ignis` + `ignis-macros`; `ignis-tui/` is a separate Node/Ink project, not a Cargo crate). -* **Run interactive TUI (default):** `cargo run` — no-arg launches the TUI. The default UI is the **Ink frontend** (`ignis-tui/`, requires Node ≥18); `IGNIS_FRONTEND=native` forces the built-in `ratatui` TUI, which is also the automatic fallback when Node is missing. (The `--tui` flag was removed in v0.15.0 — no-arg *is* the TUI.) +* **Run interactive TUI (default):** `cargo run` — no-arg launches the TUI. The UI is the **Ink frontend** (`ignis-tui/`, requires Node ≥18). The built-in `ratatui` TUI is **deprecated** (no longer actively developed) but remains the automatic fallback when Node is missing, or forced with `IGNIS_FRONTEND=native`. (The `--tui` flag was removed in v0.15.0 — no-arg *is* the TUI.) * **Run one-shot CLI:** `cargo run -- ` — streams a single turn to stdout and exits. * **Subcommands:** `ignis mcp`, `ignis upgrade` (alias `update`), `ignis sessions`. * **Other flags:** `-r, --resume [ID]`, `--afk` (fully unattended), `-v, --version`. @@ -46,11 +46,11 @@ Cross-cutting top-level files: `main.rs` (routing — `--engine` headless vs TUI * Keep changes tightly scoped to the current active goal. ### R2. TUI Design -* Two frontends share one Rust core: the **Ink frontend** (`ignis-tui/`, Node/React-Ink — default when Node ≥18 is present, since v0.40.0) and the **built-in `ratatui` TUI** (`crossterm` backend, fallback). The Ink host owns the terminal and spawns the Rust binary as a headless `--engine` over an NDJSON stdin/stdout protocol; `IGNIS_FRONTEND=native` forces the built-in. +* Two frontends share one Rust core: the **Ink frontend** (`ignis-tui/`, Node/React-Ink — default when Node ≥18 is present, since v0.40.0) and the **built-in `ratatui` TUI** (`crossterm` backend). The ratatui TUI is **deprecated** and no longer actively developed — it remains as a fallback when Node is unavailable, forced with `IGNIS_FRONTEND=native`. The Ink host owns the terminal and spawns the Rust binary as a headless `--engine` over an NDJSON stdin/stdout protocol. * The built-in TUI renders at a ~30fps frame tick (`FRAME = 33ms` in `console/runner.rs`); `AgentEvent`s stream in between frames and are coalesced into the next draw. * Use the established dark color palette (Catppuccin Mocha, defined in `src/console/colors.rs`). * Tool call blocks encode status via a color-coded **bullet** (`●`): yellow=pending, green=success, red=error (`console/render/tool_block.rs`). -* The Rust core ships as a **single binary**; the default Ink frontend additionally requires Node ≥18 (the `ratatui` TUI has no external runtime deps). +* The Rust core ships as a **single binary**; the Ink frontend additionally requires Node ≥18 (the deprecated `ratatui` TUI is the only no-runtime-deps path). ### R3. Quality & Warning Gate * Maintain **zero compiler warnings and clippy errors** in the Rust crates. diff --git a/README.md b/README.md index 2a8a9e88..fab66529 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,9 @@ Drops the binary in `~/.ignis/bin` and the Ink frontend in `~/.ignis/ignis-tui`. Already installed? Update in place with `ignis upgrade`. `ignis` runs the [Ink frontend](ignis-tui/README.md) by default when **Node ≥18** -is on your PATH, and falls back to the built-in `ratatui` TUI otherwise. Force the -built-in any time with `IGNIS_FRONTEND=native`. +is on your PATH, and falls back to the built-in `ratatui` TUI otherwise. The +ratatui TUI is **deprecated** (no longer actively developed); `IGNIS_FRONTEND=native` +forces it.
Other ways to install @@ -81,9 +82,9 @@ See [Configure](#configure) for more providers and per-model options. ## Features -- **TUI + CLI** — a terminal TUI and a one-shot CLI from the same binary. The - default UI is the [Ink frontend](ignis-tui/README.md) when Node is present, with - the built-in `ratatui` TUI as the always-available fallback (`IGNIS_FRONTEND=native`). +- **TUI + CLI** — a terminal TUI and a one-shot CLI from the same binary. The UI + is the [Ink frontend](ignis-tui/README.md) when Node is present, with the built-in + `ratatui` TUI as a deprecated fallback for Node-less environments (`IGNIS_FRONTEND=native`). - **Bring your own model** — OpenAI, Anthropic, DeepSeek, Kimi, MiniMax, Moonshot, Ollama, and any OpenAI-compatible endpoint (the `custom` provider). Providers are built in — drop in an API key and go. Switch model and reasoning @@ -102,8 +103,8 @@ See [Configure](#configure) for more providers and per-model options. gate, with a built-in safety floor and user-declarable allow/ask/deny rules. - **Sessions** — project-scoped history with `--resume`, auto-resume, and context compaction; export per-session stats with `ignis sessions export`. -- **Single binary** — the core agent is one self-updating binary with no runtime - deps; the optional Ink frontend is the only piece that needs Node. +- **Single binary** — the core agent is one self-updating binary; the Ink frontend + needs Node ≥18 (the deprecated `ratatui` TUI is the no-Node fallback). ## Configure From e02570ec9d8ca6dbb074972aaf7799166ab85052 Mon Sep 17 00:00:00 2001 From: Fullstop000 Date: Tue, 7 Jul 2026 21:12:23 +0800 Subject: [PATCH 3/4] fix(llm): avoid empty assistant content on replay --- CHANGELOG.md | 3 ++ ignis/src/llm/protocols/mod.rs | 90 ++++++++++++++++++++++++++-------- 2 files changed, 72 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1839d6a3..a0bf208d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- LLM — OpenAI-compatible requests now use a single-space placeholder, not an empty string, for degenerate assistant turns. This fixes strict gateways that reject replayed reasoning-only session records with `the message ... with role 'assistant' must not be empty`. + ### Deprecated - TUI — the built-in `ratatui` TUI (`IGNIS_FRONTEND=native`) is deprecated and no longer actively developed. It remains the automatic fallback when Node ≥18 is unavailable; the [Ink frontend](ignis-tui/README.md) is the only actively-maintained UI. diff --git a/ignis/src/llm/protocols/mod.rs b/ignis/src/llm/protocols/mod.rs index 84923aac..1c6d472e 100644 --- a/ignis/src/llm/protocols/mod.rs +++ b/ignis/src/llm/protocols/mod.rs @@ -450,22 +450,33 @@ pub(crate) fn prep_outbound_history(messages: &[Message], policy: &HistoryPolicy out } -/// Guarantee every outbound message carries a `content` field for OpenAI-compatible -/// providers. `Message::content` is `Option` and skipped when `None`, so a -/// tool-call-only assistant turn (no visible text) or a reasoning-only turn whose -/// reasoning was stripped by [`prep_outbound_history`] serializes as -/// `{"role":"assistant","tool_calls":[...]}` — or even a bare `{"role":"assistant"}`. -/// Standard OpenAI accepts that, but strict gateways (e.g. Ark/Doubao) reject it with -/// `missing messages.content parameter (MissingParameter)`, which poisons the whole -/// session because the offending history message is replayed on every subsequent turn. +/// Guarantee every outbound message carries a non-empty `content` field for +/// OpenAI-compatible providers. `Message::content` is `Option` and +/// skipped when `None`, so a tool-call-only assistant turn (no visible text) or +/// a reasoning-only turn whose reasoning was stripped by +/// [`prep_outbound_history`] serializes as +/// `{"role":"assistant","tool_calls":[...]}` — or even a bare +/// `{"role":"assistant"}`. `prep_outbound_history` can also strip inline +/// `...` down to `content: ""`. /// -/// Filling the gap with an empty string is the minimal fix: it's a valid `content` -/// value under the OpenAI spec, preserves the turn, and — because it runs at send time -/// — also unbreaks a session that was already poisoned before this shipped. Mirrors the -/// Anthropic path's placeholder handling in `map_messages_to_anthropic`. +/// Standard OpenAI accepts missing or empty assistant text in these degenerate +/// turns, but strict gateways reject either the missing field or the empty +/// assistant message. We preserve the assistant turn instead of dropping it: +/// removing it would change history shape (`user -> assistant -> user` becomes +/// adjacent user turns), which is more semantically meaningful than a whitespace +/// placeholder. The single space is deliberately boring: it satisfies +/// non-empty-content validators without inventing model-visible text such as +/// "(no visible response)". +/// +/// Keep the single-space workaround scoped to assistant turns. Other roles only +/// need the old missing-field guard, so `None` still becomes `""` for them. +/// Because this runs at send time, it also unbreaks sessions that were already +/// poisoned before this shipped. pub(crate) fn ensure_content_present(messages: &mut [Message]) { for msg in messages.iter_mut() { - if msg.content.is_none() { + if msg.role == "assistant" && msg.content.as_deref().map(str::is_empty).unwrap_or(true) { + msg.content = Some(" ".to_string()); + } else if msg.content.is_none() { msg.content = Some(String::new()); } } @@ -823,7 +834,7 @@ mod tests { assert!(prep_outbound_history(&[], &HistoryPolicy { strip_think: true }).is_empty()); } - // ---- ensure_content_present: strict-gateway `missing messages.content` guard ---- + // ---- ensure_content_present: strict-gateway content guard ---- #[test] fn ensure_content_backfills_tool_call_only_assistant_turn() { @@ -834,25 +845,62 @@ mod tests { // Simulate the stored shape: no visible text means content is None. msgs[0].content = None; ensure_content_present(&mut msgs); - assert_eq!(msgs[0].content.as_deref(), Some("")); + assert_eq!(msgs[0].content.as_deref(), Some(" ")); // The tool call is untouched. assert!(msgs[0].tool_calls.as_ref().is_some_and(|t| !t.is_empty())); } #[test] fn ensure_content_backfills_reasoning_stripped_assistant_turn() { - // A reasoning-only turn arrives here as `content: None` after - // `prep_outbound_history` clears its reasoning — a bare - // `{"role":"assistant"}` that any strict gateway 400s on. + // A persisted reasoning-only turn with no visible content arrives from + // session replay as `reasoning_content: Some(..), content: None`. + // `prep_outbound_history` clears the reasoning, leaving a bare + // `{"role":"assistant"}`; strict gateways then require a non-empty + // placeholder. let history = vec![ user("question"), - assistant_text("the answer", Some("first I considered ...")), + Message { + role: "assistant".to_string(), + content: None, + reasoning_content: Some("first I considered ...".to_string()), + name: None, + tool_call_id: None, + tool_calls: None, + created_at_ms: None, + }, ]; let mut out = prep_outbound_history(&history, &HistoryPolicy { strip_think: true }); - // Force the reasoning-only shape: content None, reasoning already stripped. - out[1].content = None; + assert!(out[1].content.is_none()); + assert!(out[1].reasoning_content.is_none()); + ensure_content_present(&mut out); + assert_eq!(out[1].content.as_deref(), Some(" ")); + } + + #[test] + fn ensure_content_replaces_empty_assistant_content() { + // `prep_outbound_history` can strip an inline `...`-only + // assistant turn down to `content: ""`. Some strict gateways reject + // that as an empty assistant message even though the `content` field is + // present. + let history = vec![ + user("question"), + assistant_text("only thought", None), + ]; + let mut out = prep_outbound_history(&history, &HistoryPolicy { strip_think: true }); assert_eq!(out[1].content.as_deref(), Some("")); + + ensure_content_present(&mut out); + assert_eq!(out[1].content.as_deref(), Some(" ")); + } + + #[test] + fn ensure_content_uses_empty_string_for_missing_non_assistant_content() { + let mut msgs = vec![user("hi")]; + msgs[0].content = None; + + ensure_content_present(&mut msgs); + assert_eq!(msgs[0].content.as_deref(), Some("")); } #[test] From 18d7c6c283cb4558a029cc9a630ac69e1b338577 Mon Sep 17 00:00:00 2001 From: Fullstop000 Date: Tue, 7 Jul 2026 21:25:42 +0800 Subject: [PATCH 4/4] chore(release): v0.45.3 --- CHANGELOG.md | 6 ++++-- Cargo.lock | 2 +- ignis/Cargo.toml | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0bf208d..50da47bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.45.3] - 2026-07-07 + ### Fixed -- LLM — OpenAI-compatible requests now use a single-space placeholder, not an empty string, for degenerate assistant turns. This fixes strict gateways that reject replayed reasoning-only session records with `the message ... with role 'assistant' must not be empty`. +- LLM — OpenAI-compatible requests no longer fail on strict gateways that reject replayed reasoning-only assistant turns as empty messages. ([#246](https://github.com/Fullstop000/ignis/pull/246)) ### Deprecated -- TUI — the built-in `ratatui` TUI (`IGNIS_FRONTEND=native`) is deprecated and no longer actively developed. It remains the automatic fallback when Node ≥18 is unavailable; the [Ink frontend](ignis-tui/README.md) is the only actively-maintained UI. +- TUI — the built-in `ratatui` TUI (`IGNIS_FRONTEND=native`) is deprecated and no longer actively developed; the Ink frontend is the only actively-maintained UI. ([#246](https://github.com/Fullstop000/ignis/pull/246)) ## [0.45.2] - 2026-07-03 diff --git a/Cargo.lock b/Cargo.lock index ac49d11e..bfa45e9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1051,7 +1051,7 @@ dependencies = [ [[package]] name = "ignis" -version = "0.45.2" +version = "0.45.3" dependencies = [ "anyhow", "async-trait", diff --git a/ignis/Cargo.toml b/ignis/Cargo.toml index 1ac674bd..10261c3d 100644 --- a/ignis/Cargo.toml +++ b/ignis/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ignis" -version = "0.45.2" +version = "0.45.3" edition = "2021" description = "A single-binary, multi-provider AI coding agent for your terminal." license = "Apache-2.0"