Skip to content

TaraTheStar/enso

ensō

ensō

CI License: AGPL v3+

ensō (円相) — the Zen circle, drawn in one breath. Imperfect by design; the irregularities are the point.

A small TUI agentic coding agent in Go (binary: enso). Talks to any OpenAI-compatible chat endpoint (designed against llama.cpp's llama-server running Qwen3.6-35B-A3B, but the LLM client doesn't care). Built-in tools: read, write, edit (with diff prompt), bash, grep, glob, web_fetch, web_search (DuckDuckGo by default; opt into SearXNG via [search.searxng] endpoint = "..."), todo, memory_save. Sessions persist to SQLite and resume across crashes.

📖 Full documentation: https://tarathestar.github.io/enso/

(also available offline as Markdown in docs/content/).

Quickstart

1. Start llama-server

The default config (written on first run to $XDG_CONFIG_HOME/enso/config.toml, ≈ ~/.config/enso/config.toml) assumes a server on http://localhost:8080/v1 serving model id qwen3.6-35b-a3b. On a single RTX 3090:

llama-server \
  -m unsloth/Qwen3.6-35B-A3B-GGUF/UD-Q4_K_XL.gguf \
  -ngl 999 -c 32768 -fa on --no-mmap \
  -ctk q8_0 -ctv q8_0 \
  --jinja --reasoning-budget 4096 --reasoning-budget-message \
  --temp 0.6 --top-k 20 --top-p 0.95 --min-p 0.0 --presence-penalty 1.5 \
  --port 8080

Any other OpenAI-compatible endpoint will work; edit ~/.config/enso/config.toml to point elsewhere.

2. Build

make build           # produces ./bin/enso
# or
go build -o enso ./cmd/enso

Common Makefile targets: make run (builds + launches the TUI), make daemon, make test, make check (gofmt + vet + test + build), and make help for the full list.

3. Run

Interactive TUI:

./enso                  # or ./enso tui
./enso --yolo           # auto-allow all tool calls (no permission prompts)
./enso --session <id>   # resume a prior session (alias: --resume <id>)
./enso --continue       # resume the most recently updated session

Non-interactive (one-shot, streams to stdout):

./enso run --yolo "list the .go files in cmd/"
echo "summarise README.md" | ./enso run --yolo
./enso run --yolo --format json "..."   # newline-delimited bus events on stdout

Export a finished session to markdown:

./enso export <session-id>          # to stdout
./enso export <session-id> -o session.md

Configuration

Config files are layered. From lowest to highest priority:

  1. /etc/enso/config.toml (system)
  2. $XDG_CONFIG_HOME/enso/config.toml (user; falls back to ~/.config/enso/config.toml)
  3. <cwd>/.enso/config.toml (project, committed)
  4. <cwd>/.enso/config.local.toml (project, gitignored — "Remember" rules land here)
  5. The path passed via -c <file> (highest)

Each file is merged key-by-key — later layers override individual keys without replacing whole tables. If no config file exists anywhere, the default is written to the user path on first run.

enso config init           # write the default to the user path
enso config init --wizard  # interactive: pick a provider preset, model, optional API key
enso config init --print   # dump the default to stdout
enso config init --force   # overwrite an existing file
enso config show           # list the search paths and which exist
enso trust                 # trust ./.enso/config.toml (records sha256 in ~/.local/state/enso/trust.json)
enso trust --list          # show every trusted entry
enso trust --revoke        # forget the entry for ./.enso/config.toml

Key sections of the config:

# Top-level scalar — picks which [providers.X] is active at session
# start. Must appear before any [providers.X] table; if unset, the
# alphabetically-first provider name wins. Switch mid-session with
# /model <name>.
default_provider = "local"

[providers.local]
endpoint = "http://localhost:8080/v1"
model = "qwen3.6-35b-a3b"
context_window = 32768
concurrency = 1

[providers.local.sampler]
temperature = 0.6
top_k = 20
top_p = 0.95
min_p = 0.0
presence_penalty = 1.5

[permissions]
mode  = "prompt"   # "prompt" | "allow" | "deny"  (fallback for un-matched calls)
allow = []         # e.g. ["bash(git *)", "edit(./src/**)", "web_fetch(domain:example.com)"]
ask   = []         # e.g. ["bash(git push *)"] — always prompt even when otherwise allowed
deny  = []         # e.g. ["bash(rm -rf *)", "edit(./.env)"]
additional_directories = []   # extra workspace dirs beyond cwd; surfaced in
                              # the system prompt + the @-file picker
disable_file_confinement = false  # when false (default), file tools (read/
                              # write/edit/grep/glob/lsp_*) refuse any path
                              # outside cwd + additional_directories. Set
                              # true to let the model roam the filesystem.

[git]
attribution = "none"          # "co-authored-by" | "assisted-by" | "none"
attribution_name = "enso"

[ui]
theme       = "dark"
editor_mode = "default"       # "default" | "vim" — vim adds normal-mode hjkl, w/b, x, i/a/A/o/O, Enter to submit
status_line = ""              # text/template; replaces the right-side bar. Vars:
                              #   .Provider .Model .Session .Mode .Activity .Tokens .Window .TokensFmt .TokensPerSec
                              # Example: "{{.Mode}} | {{.Model}} | {{.TokensFmt}}"

[hooks]
on_file_edit   = ""           # shell command run after edit/write succeeds; vars: .Path .Tool
on_session_end = ""           # shell command run when the agent loop returns; vars: .SessionID .Cwd
on_event       = ""           # per-event observer hook (see "External observers"); JSON on stdin
# on_events    = []           # explicit allowlist; omitted = curated default (excludes per-token deltas)
# [hooks.env]                 # extra env merged onto every hook subprocess

[web_fetch]
allow_hosts = []              # opt local hosts back through the SSRF guard
                              # (web_fetch refuses loopback / private / link-local IPs by default).
                              # Entries are exact host or host:port matches; a host without a port
                              # matches any port. Example: ["localhost:8080", "127.0.0.1:11434"].

[search]
provider = ""                 # "" (auto) | "searxng" | "duckduckgo" | "none".
                              # Auto: SearXNG when [search.searxng] endpoint is set, DDG otherwise.
                              # "none" suppresses the web_search tool entirely.

[search.searxng]
endpoint    = ""              # e.g. "http://localhost:8888" or "https://searx.be"
categories  = []              # ["general", "it", ...] — empty leaves SearXNG default
engines     = []              # ["google", "duckduckgo", ...] — empty leaves SearXNG default
max_results = 10              # ceiling; the model can ask for fewer
api_key     = ""              # optional — sent as Authorization: Bearer; "$ENSO_*" refs expanded
timeout     = 15              # seconds
ca_cert     = ""              # PEM bundle to trust (self-hosted CA); appended to system roots
insecure_skip_verify = false  # disable TLS verification — last-resort escape hatch

Patterns are tool(arg-pattern). Per-tool matching:

  • bash(git *) — first-word match for single tokens, full-glob for multi-word (bash(git push *) properly scopes to git push). Allow rules gate on shell metacharacters: any of ; & | < > $ ` ( ) \ newline present in the command must also appear in the pattern, so bash(git *) will NOT auto-allow git status; rm -rf ~. Opt in explicitly with patterns like bash(git * | *) if you need pipes. Deny rules are segment-aware: bash(rm -rf *) blocks chained variants like do_evil; rm -rf /, cd / && rm -rf *, and ls | rm -rf * by splitting on top-level shell separators. Deny rules are guardrails, not walls — they don't recurse into command substitution ($(...), backticks) or eval. For real isolation against a hostile model or hostile codebase, set bash.sandbox = "auto".
  • read(**) / write(./src/**) / edit(./.env) / grep(...) / glob(...) — strict doublestar path globs against the absolute path. No basename fallback: read(*.md) does NOT match /anywhere/foo.md (that would let a bare extension pattern exfiltrate any file with that suffix). Use read(**/*.md) for "any .md file" or read(./**/*.md) to scope it to the project.
  • web_fetch(domain:example.com) — match by URL host (subdomains included).
  • Anything else — generic doublestar match.

Precedence: denyaskallowmode default. Ask wins over allow, so a broad bash(*) allow plus an ask on bash(git push *) still prompts before pushing.

Drop a .ensoignore at the project root with one glob per line (gitignore-style, no ! negation) to auto-deny read/write/edit/grep/glob for those paths and hide them from the @-picker. Lines starting with # are comments.

When [git] attribution is non-none, the system prompt instructs the model to append a matching trailer (Co-Authored-By: <name> / Assisted-by: <name>) to any commit message it writes on your behalf.

Secrets

Provider api_key and MCP headers / args accept $ENSO_FOO / ${ENSO_FOO} env-var references. Only ENSO_-prefixed names expand; anything else collapses to "" (logged once). Keep a committable config that references the names, set the values from your shell:

# ~/.config/enso/config.toml — committable; no actual secret here.
[providers.cloud]
endpoint = "https://api.example.com/v1"
model    = "some-model"
api_key  = "$ENSO_CLOUD_KEY"
export ENSO_CLOUD_KEY="sk-..."

The prefix gate exists because trusting a project once shouldn't let a later commit ship api_key = "$AWS_SECRET_ACCESS_KEY" to a hostile endpoint. Per-repo secrets follow the same pattern: commit .enso/config.toml with $ENSO_* refs, set the values via direnv or your shell. See docs/secrets for the full story.

Project instructions (ENSO.md / AGENTS.md)

The system prompt is built from layered files, appended in order:

  1. The default prompt embedded in the binary.
  2. ~/.config/enso/ENSO.md (if present) — appended.
  3. The closest ENSO.md walking up from the cwd — appended.
  4. The closest AGENTS.md walking up from the cwd — appended.

Any of these files may carry --- replace: true --- frontmatter, which discards every earlier layer (e.g. a user ENSO.md that fully replaces the embedded default, or a project file pinning a team-shared canonical prompt). Run /prompt in the TUI to see the per-layer breakdown.

TUI keybindings

Key Action
Enter Submit (or run a /-prefixed slash command)
Ctrl-C / Ctrl-D Quit (Ctrl-D with non-empty input clears the line first)
Esc Close modal (= Deny on permission prompt). Double-tap clears the input line.
Ctrl-Space (= Ctrl-@) Toggle the alt-screen session-inspector overlay
Ctrl-R Open recent-sessions overlay (Enter switches session — re-execs with --session <id>)
Ctrl-A / Home Move to start of input line
Ctrl-E / End Move to end of input line
Ctrl-Left / Ctrl-Right Word back / word forward
@ (at token start) Open file picker — type to filter, Enter inserts the path
Permission prompt: y / n / a / t Allow / Deny / Allow + Remember / Allow for this turn only

When [ui] editor_mode = "vim" is set, the input runs a single-line vim subset: Esc enters NORMAL, i / a / A re-enter INSERT, h l 0 $ w b x for navigation and edit. Submission stays on Enter in either mode.

Slash commands

Command Description
/help List available commands
/yolo on|off Toggle auto-allow mode
/tools List registered tools (built-ins + MCP + LSP)
/info Print the active provider, model, session id, cwd, and config search paths
/sessions List recent sessions (resume with --session <id>; Ctrl-R is the overlay version)
/rename [<label>] Show or override the session's display label (no arg prints current; /rename <text> overrides)
/export [-o <file>] Export this session to markdown (stdout by default)
/fork Branch this session into a new one and print the new id
/stats [--days N] Token / message / tool aggregates across sessions
/find [-e] <pattern> Search this session's transcript (-e for regex)
/grep [--all] [--regex] <pattern> Search past sessions in the local store
/permissions [remove <pattern>] List & remove project-local permission rules
/model [<name>] List configured providers, or switch the active one
/compact Force a context-compaction pass
/init [target] Survey the project and write ENSO.md (or a chosen filename)
/agents List declarative agent profiles
/loop <interval> <prompt> Re-submit a prompt every interval (≥5s); /loop off stops
/workflow <name> <args> Run a declarative workflow
/lsp Configured language servers and their state
/mcp Configured MCP servers, state, and tool counts
/git Current branch + working-tree status
/cost Cumulative token totals for this session
/transcript [<id>] List captured subagent transcripts; show one with the id-or-prefix
/<skill-name> <args> Any user-defined skill (project shadows user)
/quit Exit

Sessions

Sessions live in ~/.local/share/enso/enso.db (SQLite, pure-Go via modernc.org/sqlite). Every user message, assistant reply, and tool result is persisted before the UI sees it — kill the process mid-tool-call and the session resumes with the interrupted call surfaced as a synthetic tool result.

Use --ephemeral to skip persistence.

External observers

Two supported integration shapes for third-party tools that want to visualise, log, or react to agent activity without embedding into enso itself.

1. on_event hook (preferred for low-volume observers). Set a hook command in config.toml; enso spawns it per event with the event as JSON on stdin. No extra daemon, no socket, no lifecycle to manage — just a config entry.

[hooks]
on_event = "node /path/to/dispatch.js"
# Optional explicit filter (default excludes per-token deltas; see
# internal/hooks.DefaultEventFilter for the curated set).
# on_events = ["UserMessage", "ToolCallStart", "ToolCallEnd", "AgentIdle"]

[hooks.env]
# Merged onto every hook subprocess's environment. Lets you keep
# config out of your shell rc. The keys here are adapter-specific —
# this block is just an example for the watchourai dispatch script.
WATCHOURAI_URL          = "http://localhost:3456"   # watchourai server
WATCHOURAI_TOKEN        = "..."                     # from office Tokens modal
WATCHOURAI_REGISTER     = "enso"                    # identity prefix; cwd hash auto-appended
WATCHOURAI_DISPLAY_NAME = "TaraTheStar (enso)"            # friendly label

The JSON on stdin: {"session_id": "...", "cwd": "...", "type": "ToolCallStart", "payload": {...}}. Mirrors what daemon-socket subscribers see. Process is given a 10s timeout and runs off the agent's hot path. Failures log; non-zero exits are silent (matches the existing on_file_edit / on_session_end posture).

2. Daemon-socket subscription (high-volume / stateful observers). enso daemon exposes its event bus over a Unix socket at $XDG_RUNTIME_DIR/enso/daemon.sock (or ~/.enso/daemon.sock if XDG is unset). Length-prefixed JSON; events carry session_id so multi-session observers route without keeping outer context. Source of truth: internal/daemon/protocol.go and internal/bus/bus.go (Event.WireForm).

Worked example for both shapes: the watchourai enso-bus adapter (socket subscriber) and its enso-hooks sibling (hook dispatcher) translate agent events into a status board.

Status

v2 ships the Bubble Tea TUI migration; the binary is in daily use. See CHANGELOG.md for the per-release breakdown. Headline features:

  • Scrollback-native interactive TUI (Bubble Tea v2 + Lipgloss + Glamour for markdown), with streaming, tool-calling, and an alt-screen session-inspector overlay (Ctrl-Space).
  • Sessions + crash resume + auto-compaction at 60% of the context window.
  • Permission allowlist with prompt-on-miss (y / n / a / t decisions — turn-scoped grants land in t); --yolo for unattended runs. Bash deny rules are segment-aware so bash(rm -rf *) catches chained variants like cd / && rm -rf *.
  • enso run non-interactive mode (with --detach to submit to a daemon, --format json for streaming structured events).
  • enso export <id> to dump a session as markdown.
  • enso stats [--days N] for token / message / tool aggregates across sessions.
  • enso fork <id> to branch an existing session into a fresh one.
  • --continue / --resume <id> for picking up where you left off.
  • --worktree to spin up a fresh git worktree (~/.local/state/enso/worktrees/<repo>-<rand> on enso/<rand>) and run the session there.
  • --agent <name> to pick a declarative profile (built-in plan, plus user / project agents).
  • Multiple [providers.X] blocks; default_provider = "..." picks the active one and /model <name> swaps it mid-session.
  • [lsp.<name>] config to surface lsp_hover/lsp_definition/lsp_references/lsp_diagnostics tools (any language server).
  • [git] config block to opt into commit attribution trailers.
  • spawn_agent tool for subagents (depth ≤3, global cap 16).
  • MCP client (stdio + Streamable-HTTP) auto-registers remote tools, with sidebar health tracking for failed servers.
  • web_search tool (DuckDuckGo by default; SearXNG via [search.searxng] endpoint).
  • Auto-derived and manual session labels (/rename); LLM connection-state tracking with a background recovery probe.
  • Slash commands listed above (and user-defined skills).
  • Declarative workflows (planner→coder→reviewer style) with parse-time validation.
  • enso daemon + enso attach for long-running detached sessions, with daemon-side permission timeouts and TUI countdown indicators.
  • Interactive enso config init --wizard flow for first-run onboarding.

Subagentsspawn_agent tool. Depth ≤3, global cap 16; child shares parent's provider/bus/permissions. Subagent transcripts are captured for inspection; list them with /transcript and view one with /transcript <id-or-prefix>. The session-inspector overlay (Ctrl-Space) also surfaces in-flight agent state at a glance.

MCP — servers (stdio or Streamable-HTTP) are configured under [mcp.<name>] in config.toml. Their tools surface as mcp__<server>__<tool> in /tools and the permission matcher.

Bash sandboxing[backend] type = "podman" (or "lima") routes the agent through a per-project container/VM. Project cwd is bind-mounted at /work; the agent's shell can't see ~, ~/.ssh, sibling repos, or anything else outside cwd. File tools (read/write/edit/grep/glob) get a parallel cwd-confinement guard so they can't bypass the sandbox via path arguments. Each task runs in a fresh podman run --rm container (enso-<basename>-<taskid>); init runs in-container before the worker. Lima instead uses a persistent per-project VM, recreated automatically when its config changes. Reclaim stragglers (terminal podman workers, idle lima VMs) with enso prune [--older-than].

[backend]
type = "podman"             # "local" | "podman" | "lima"

[backend.podman]
image = "alpine:latest"
init  = ["apk add --no-cache git curl jq make"]
network = ""                # "" inherits runtime default; "none" = offline
extra_mounts = ["~/.cache/go-build:/root/.cache/go-build:rw"]

The init list re-runs only when this config (image / init / mounts / env) changes — tracked via a label on the container. Manual edits to the container's contents (e.g. apk add from inside) survive across enso runs but are lost when the config changes.

LSP — configure language servers under [lsp.<name>] to surface lsp_hover, lsp_definition, lsp_references, and lsp_diagnostics tools. Servers are spawned lazily on first use, scoped by file extension, and reused for the rest of the session. Works with any LSP-compliant server (gopls, rust-analyzer, typescript-language-server, pyright, clangd, ruby-lsp, …) — the config is fully language-agnostic. See enso config init --print for example blocks. Daemon-mode sessions do not currently expose these tools.

[lsp.go]
command = "gopls"
extensions = [".go"]
root_markers = ["go.mod", ".git"]

[lsp.typescript]
command = "typescript-language-server"
args = ["--stdio"]
extensions = [".ts", ".tsx", ".js", ".jsx"]
root_markers = ["package.json", "tsconfig.json", ".git"]
init_options = { preferences = { quotePreference = "single" } }

This repo ships its own <repo>/.enso/config.toml with [lsp.gopls] pre-wired, so contributors get definition/references/hover for free — provided gopls is on PATH. If it isn't:

go install golang.org/x/tools/gopls@latest

The first launch in any repo with a committed .enso/config.toml prompts to trust the file (one-time, recorded in ~/.local/state/enso/trust.json).

Auto-memory — call the memory_save tool to persist a fact across sessions. Files land at <cwd>/.enso/memory/<slug>.md (project) and are auto-loaded into the system prompt at the start of every future session. User-global memories at ~/.local/share/enso/memory/<slug>.md work the same way; project files shadow user files on name collision. Save things that are non-obvious and stable — preferences, project facts, prior corrections ("don't mock the database in integration tests"), not in-progress work or anything already in code/git history. Inspect with ls ~/.local/share/enso/memory/ or ls .enso/memory/; delete with rm.

Agents — declarative profiles select a different system-prompt appendix, tool restriction, and sampler for the session. Built-in: plan (read-only investigation; bash/write/edit removed). Drop a frontmatter-headed ~/.config/enso/agents/<name>.md or ./.enso/agents/<name>.md to add your own; project shadows user, user shadows built-in. Frontmatter fields: name, description, allowed-tools, denied-tools, temperature, top_p, top_k, max_turns. The body is the prompt appended to the base system prompt. Pick at startup with --agent <name>; list available agents in the TUI with /agents. (Mid-session switching is not yet supported. Per-agent model: is also not yet wired — pick a different provider per session with /model, per workflow-role with the role's model: field, or per spawn_agent call with the tool's model arg.)

Skills — drop a frontmatter-headed markdown file at ~/.config/enso/skills/<name>.md or ./.enso/skills/<name>.md and /<name> becomes a slash command that expands the body as the next user message. Frontmatter fields: name, description, allowed-tools, model. Body is a text/template with {{ .Args }}.

Workflows — declarative agent pipelines in ~/.config/enso/workflows/<name>.md (or project-local ./.enso/workflows/<name>.md). Frontmatter declares roles + edges; the body has one ## <role> section per agent with a text/template prompt. Run via /workflow <name> <args> in the TUI or enso run --workflow <name> "<args>" from the CLI. See examples/workflows/build-feature.md for a planner→coder→reviewer pipeline.

Theme — drop a ~/.config/enso/theme.toml to override the default colour palette. Each entry is a hex #rrggbb, mapped onto the named colours the chat / overlay code uses (yellow, teal, gray, red, green, plus the accent names mauve, lavender, comment, dust, sage):

[colors]
yellow = "#ffd866"
teal   = "#78dce8"
gray   = "#727072"
red    = "#ff6188"
green  = "#a9dc76"

A typo in this file logs a warning to ~/.local/state/enso/enso.log and falls back to defaults; it never blocks the TUI.

Daemon mode — POSIX-only (Linux/macOS/BSD; Windows users run via WSL). The daemon path is intentionally narrower than the in-process path: lsp_* tools and the sandbox backend are not registered for daemon sessions — each enso run --detach can target a different cwd, but the registry is shared across sessions, and per-session LSP / sandbox managers are out of v1 scope. Use enso run or enso tui (in-process) when you need those tools.

enso daemon runs a long-lived agent server on a unix socket at $XDG_RUNTIME_DIR/enso/daemon.sock. Pass --detach to fork into the background and return immediately (the parent prints the child PID and the socket path; running --detach again while a daemon is up just says "daemon already running"). enso run --detach "<prompt>" submits a fire-and-forget job (yolo by default — no UI to prompt) and prints the session id. enso attach <id> opens a TUI driven by the live event stream from the daemon; permission prompts proxy back through the socket so you can answer them locally. If no client is attached the daemon denies after a 60s timeout. Attach reconnects automatically on daemon restart — the events cursor is preserved via from_seq so any events still in the ring buffer replay.

See AGENTS.md for the maintainer's reference (operating conventions, non-goals, soak-test risks).

License

ensō is licensed under the GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later). The full text of v3 is in LICENSE; "or later" means you may also redistribute under any later version published by the Free Software Foundation.

About

Small TUI coding agent in Go for any OpenAI-compatible endpoint. Built-in read/write/edit/bash/grep/web_fetch tools, sandboxed shell, SQLite session persistence.

Topics

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages