e-agent is a small streaming Rust coding agent for OpenAI-compatible
/chat/completions APIs and the ChatGPT Codex /responses API. It sends a
prompt to a model, executes requested tools one at a time inside a workspace,
returns each result to the model, and prints the model's final answer.
The usual setup needs no environment variables. Create
$XDG_CONFIG_HOME/e-agent/config.toml (or, when that is unset or absent,
$HOME/.config/e-agent/config.toml):
default = "kimi/k3"
[providers.kimi]
base_url = "https://api.kimi.com/coding/v1"
api_key_file = "kimi-key" # absolute or relative to this config file
# Alternatively: api_key_env = "KIMI_API_KEY"
[models."kimi/k3"]
model = "k3"
# Optional: Kimi Coding k3 accepts "low", "high", or "max".
reasoning_effort = "high"
# Optional: set true when the model accepts image input (attached via the
# `read_image` tool or the REPL `/image <path>` command). Defaults to false;
# a user message with images on a model without `vision = true` fails with
# "model X does not support image input". kimi/k3 and ChatGPT/Codex vision
# models should set it; deepseek-style models keep the default false.
vision = trueProvider names are inferred from the part of the profile before /. API-key
providers need exactly one of api_key_file or api_key_env; key files are
trimmed and must not be empty. reasoning_effort is an optional pass-through
request field with no CLI or environment override. For Kimi Coding k3, its
canonical values are low, high, and max; omitting it uses Kimi Coding's
high default. Other providers may define different values.
DeepSeek Chat thinking mode has a wire contract of its own. deepseek_compat
defaults to false, so every profile that does not set it — OpenAI, Kimi,
any other OpenAI-compatible provider, and the Codex Responses wire — keeps
the pre-existing standard wire unchanged: persisted reasoning is
display/audit only and is never replayed. Setting deepseek_compat = true
on a profile explicitly opts that profile into the DeepSeek Chat
compatibility wire:
content: ""instead of an absent/nullcontenton content-less assistant turns — gated bydeepseek_compatalone;- when
thinking = trueas well, assistant turns that carrytool_callsecho their originalreasoning_content(including an empty string) back to the API on the next request — DeepSeek returns a 400 "reasoning_content must be passed back" error without it. Plain assistant reasoning (no tool calls) is never replayed, even with both flags set.
default = "deepseek/deepseek-chat"
[providers.deepseek]
base_url = "https://api.deepseek.com/v1"
api_key_env = "DEEPSEEK_API_KEY"
[models."deepseek/deepseek-chat"]
model = "deepseek-chat"
reasoning_effort = "high"
thinking = true
# Explicit DeepSeek Chat wire compatibility. Defaults to false; profiles
# that leave it unset keep the standard wire unchanged.
deepseek_compat = trueChatGPT/Codex profiles use the separate Responses API and browser login; they do not accept an API key or base URL:
[providers.chatgpt]
auth = "chatgpt"
[models."chatgpt/gpt-5.6-sol"]
model = "gpt-5.6-sol"
reasoning_effort = "high"Run e-agent login to complete the browser login, then select that profile as
usual. e-agent logout removes only $XDG_CONFIG_HOME/e-agent/auth.json (or
$HOME/.config/e-agent/auth.json). ChatGPT credentials are never stored in a
workspace session. --model is allowed for these profiles; --base-url is
rejected because the Codex endpoint is fixed.
The default delegated model can be routed with [roles] subagent; named
role templates can also be routed by role name. Role templates are discovered
from $XDG_CONFIG_HOME/e-agent/agents/<role>.md (falling back to
~/.config/e-agent/agents/) and <workspace>/agents/<role>.md, with the
workspace file winning. This repository includes six role templates —
designer, explorer, fixer, main, oracle, and seer; unrouted named
roles fall back to the default subagent model, which itself falls back to the
main profile:
[models."kimi/k2"]
model = "k2"
[roles]
subagent = "kimi/k2"A project-local <workspace>/.e-agent/config.toml overlays default,
[models], [roles], [mcp], [tui], and [pet] onto this global config: models
merge by name (a project model replaces the same-named global model; global
models the project does not define keep their definitions; an absent or empty
[models] keeps all global models), roles merge per key, and MCP servers
merge by name the same way (a project server replaces the same-named global
server; servers the project does not define survive). A project default
replaces the global default profile when present; no project default keeps
the global one, and it participates in the normal priority chain — an
explicit --profile wins over it, and it wins over [roles] main. The
project file cannot change [providers], [web_search], or [session], and
it only takes effect when a global config exists (it is an override layer,
not a standalone config). [sandbox], [background], and [bash] project overrides are described in the relevant sections
below.
The project file is strict: unknown sections (a typo, or a section the
project file cannot carry) are a startup error naming the offending field
instead of being silently ignored. And since a project [mcp] server starts
the command it configures, <workspace>/.e-agent/config.toml is trusted
input — treat it like the global config: only open workspaces you trust,
because opening a workspace runs the MCP commands that workspace's file
declares. A global [mcp."<name>"] enabled = false is a kill switch that a
project file cannot re-enable (see the MCP section). [code_mode] is
user-level only for the same reason (see "Code mode: run_rust").
The web UI carries no pet artwork and renders no desktop pet by default. To
show one, add a user-supplied local sprite sheet under [pet] in the global or
project config; the browser reads it through the authenticated server and never
receives an arbitrary file path:
[pet]
spritesheet = "/absolute/path/to/maid.webp"
sprite_cols = 8 # default 8
sprite_rows = 11 # default 11
frame_width = 192 # default 192
frame_height = 208 # default 208
idle_row = 0 # default 0; one row is one animation state
idle_frames = 6 # default 6; row 0 of the bundled sheet has 6 real frames —
# set the chosen row's real frame count
loop_ms = 1200 # default 1200; duration of one idle-row cycle
# (the UI honors at least ~16 ms per frame, so
# effective cycles never go below 16 * idle_frames)Only PNG, JPEG, WebP, and GIF files up to 10 MiB are served. Each sprite-sheet
row is treated as a separate animation state: idle_row stays fixed while
idle_frames controls how many cells in that row play. Remove the [pet]
section (or its spritesheet key) to hide the pet entirely. The pet
remains hidden if config or sprite loading fails, and it is available on narrow
mobile screens when configured. The config is read live, so saving it and
reloading the page does not require a server restart. A project [pet] section replaces the global section
wholesale, like [tui]; omitted fields use the built-in defaults rather than
global field values. Sprite files are user-supplied and are never embedded in
or distributed with e-agent.
The TUI's input boxes accept a limited set of Enter variants configured in
the global config under [tui] — e.g. swap the roles so Enter inserts a
newline and Option+Enter submits:
[tui]
submit = "alt+enter" # default "enter"
newline = "enter" # default "alt+enter"Each field accepts exactly one of enter, alt+enter, option+enter (the
macOS alias for alt+enter), ctrl+enter, or shift+enter, matched
literally. submit must differ from newline; an unsupported key string or
a submit == newline collision is a startup error listing the supported
keys (no silent fallback). A project [tui] section replaces the global one
wholesale — fields the project omits fall back to the built-in defaults, not
to the global values; no project [tui] keeps the global mapping (see the
project-config paragraph above).
Terminal caveat: shift+enter is unreliable — most terminals do not
report the Shift modifier and send a bare Enter instead, so a
shift+enter binding behaves like enter there. Prefer alt+enter (or
option+enter) for the secondary key; it is the only modifier most
terminals report alongside Enter.
A role template can declare itself read-only with a leading TOML frontmatter
block: the file's first line must be exactly ---, the block may contain
read_only = true (unknown keys are ignored), and a second --- line closes
it. The prompt is everything after the closing delimiter:
---
read_only = true
---
Audit the workspace and report findings; do not modify anything.A read-only role gets no write_file/edit_file tools. Its bash, when the
sandbox is enabled, runs in a narrowed bubblewrap policy: the workspace is
mounted read-only, extra writable roots are dropped, and the network is
disabled (--unshare-net). read_file, get_background_tasks,
cancel_background_task, and web_search (when configured) stay available —
web_search deliberately still reaches the network. Background bash commands
started inside the subagent inherit the role's narrowed sandbox too (they
never fall back to the parent's wider policy). If the sandbox is not enabled
(or bwrap is unavailable), a read-only role gets no bash tool at all —
fail closed rather than an unsandboxed shell.
Read-only is a tool boundary, not a host security framework: it constrains
which tools the model may call, and the bash sandbox remains a best-effort
bwrap policy (see "Safety boundaries"). Inside that sandbox /tmp and the
$HOME tmpfs remain writable scratch space; the file tools are the
authoritative write path, and they are absent.
The main session can request the same policy with --read-only: no
write/edit tools, no MCP tools (MCP tools carry no read-only marker), and the
main bash sandbox narrowed the same way — no bash at all when the sandbox is
disabled. Delegation stays available (spawning a subagent does not mutate the
host session), and each subagent resolves its own role template.
--read-only narrows only the main session's own tools; it is not
inherited by delegated subagents. A delegate subagent with no role override
resolves its default role, which carries the full toolset — including
write_file/edit_file and a writable workspace — so a subagent spawned from
a --read-only session can still write to disk. To keep subagents
read-only too, give their role template a read_only = true frontmatter as
described above; forced inheritance of the parent's read-only policy into
subagents is a planned future option, not implemented today.
Subagent/fixer bash protects the workspace .git metadata by default
(protect_git = true): on Linux/macOS the sandbox binds <workspace>/.git
read-only so a delegated agent cannot delete or corrupt the repository (the
main agent leaves .git writable so it can orchestrate git operations). The
same frontmatter block can opt a role out with protect_git = false:
---
protect_git = false
---
Fix the code and run the checks.This matters on Windows: the write-sandbox MVP has no way to make .git
read-only, so when protect_git = true it fails closed — every shell command
in a delegated subagent/fixer is rejected before execution with guidance in
the error message. Setting protect_git = false in the role's frontmatter
(and accepting that .git stays writable inside the sandbox) restores a
working shell for that role. The key defaults to true when omitted, so
Linux/macOS behavior is unchanged; only roles that explicitly opt out lose the
.git protection.
The bash tool can be sandboxed with bubblewrap when it is installed. The
sandbox is off unless explicitly enabled:
[sandbox]
enabled = true
# network = false # unshare the network namespace (default: shared)
# workspace_writable = false # mount the workspace read-only (default: writable)
# Extra mounts, e.g. toolchain caches the agent needs inside the sandbox:
# writable_paths = ["/mnt/big/cargo-home"]
# readable_paths = ["~/.rustup", "~/.local"]When the sandbox is enabled, the workspace itself becomes a logical policy
entry for the file tools: with workspace_writable = false the file tools
deny writes/edits/removes inside the workspace (reads stay allowed), exactly
like the read-only Bash mount, while an explicit writable child path still
wins by the most-specific-entry rule and the workspace entry wins over an
external ancestor or an equal external destination. When the sandbox is
disabled, the workspace is not a policy entry and the file tools keep the
historical always-writable workspace (Bash is unsandboxed); external roots
stay active either way.
writable_paths / readable_paths define one resolved policy shared by Bash
mounts and the file tools' external capabilities. A leading ~ expands to the
home directory; relative paths resolve against the main workspace. Existing
file/directory roots are canonicalized (including symlink aliases); only
identical (canonical source, configured destination) pairs are deduplicated,
while two configured aliases of the same canonical source with different
destinations are both preserved as separate mount entries (the canonical
authority itself is never duplicated — the path vectors still deduplicate by
source). Missing global roots are skipped. Each configured root is
kept as a (canonical source, configured logical destination) pair: Bash
mounts the canonical source at the configured destination, and the file tools
open their capability from the canonical source but look up absolute paths by
the configured logical destination (the path the user wrote, before
canonicalization). Lookup is lexical — the input path is never ambiently
canonicalized — and picks the single most-specific logical winner among all
entries; an equal destination
resolves to the workspace entry. The winner's mode is then enforced: a
writable operation against a most-specific read-only winner is rejected
outright and never falls back to a broader writable parent (reads select the
same winner). The same canonical source can therefore exist
independently as a canonical read-write entry and an alias read-only entry
(e.g. a ~/.cargo symlink onto a canonical writable root: the alias stays
read-only while the canonical path stays writable).
A project-local <workspace>/.e-agent/config.toml merges with these global
roots instead of replacing them. A project path that is a strict subpath of a
global root replaces that global root (narrowing it — the global ancestor is
dropped, so only the project subpath remains), while a project path with no
ancestor relationship is accumulated alongside the global roots. A project
path equal to a global root is a no-op, and multiple project subpaths of the
same global root all survive as separate narrowing points. An absent
file/section — or a [sandbox] with no paths — inherits all global roots
unchanged. If either path field appears, selection mode applies and the other
field is empty. Readable selections may come from global readable or writable
roots (downgrading authority), while writable selections must come from global
writable roots: the user-level [sandbox] defines the authorized roots, and a
project can only narrow or accumulate within them — it can never widen them.
A project path outside every global root is a startup error naming the
offending path and the fix: add that path or an ancestor to the user-level
[sandbox] (e.g. writable_paths for writes), or remove/narrow the
project-local entry. With no user-level [sandbox] at all, project path
fields are rejected with the same guidance; project scalar overrides such as
enabled = true still apply without any global [sandbox]. Malformed or
unreadable project config is a startup error. The project [sandbox] scalars
enabled, network, and workspace_writable override the global keys
per-key: a key present in the project config replaces the global value, and
absent project keys keep the global values (so a project can turn the sandbox
on or off, or flip only the network policy, without restating the paths). A
read-only child under a
writable parent is rejected rather than silently restoring write authority;
select a narrower writable root or downgrade the whole selected root instead.
After narrowing, the configured mounts are filtered by the final canonical
roots: a stale mount whose canonical source is no longer inside any final
writable root (a narrowed-away global writable parent) is dropped so Bash
cannot regain the removed authority through the stale bind, while unrelated
global mounts and independent read-only alias mounts whose source lies inside
a final writable root survive.
The exact workspace-relative .e-agent/config.toml policy file is readable by
file tools but write/edit protected and must be changed by the user outside the
agent. Bash binds an existing policy over /dev/null; if only .e-agent/
exists, that directory is mounted read-only so a writable child cannot create
the policy. If neither exists, no mountpoint is needed or created.
Note that the sandbox hides all of $HOME behind a fresh tmpfs and then
binds in only the workspace and these extra paths — so personal files such as
~/.ssh, ~/.gitconfig, or ~/.config are NOT visible inside the sandbox
unless explicitly listed in readable_paths. Toolchains installed under
$HOME (rustup, pyenv, nvm, ...) must be listed for the agent to use them.
See "Safety boundaries" for what the sandbox does and does not constrain.
Then run:
cargo run -- "inspect src/main.rs and explain it"Without a TOML config, e-agent keeps its existing OpenAI environment-variable mode:
export OPENAI_API_KEY=...
export OPENAI_BASE_URL=https://api.openai.com/v1
export OPENAI_MODEL=gpt-4o-mini
cargo run -- "inspect src/main.rs and explain it"When no prompt argument is supplied and stdout is a terminal, e-agent starts
an interactive TUI (scrollback plus an input line with proper Unicode editing);
use --repl for a plain line-based REPL instead. If stdin is piped, the prompt
is read from standard input. Sessions persist as an append-only log of
message and compaction entries; the default SQLite backend persists them
to .e-agent/sessions.db in the workspace (JSONL remains selectable via
[session] backend = "jsonl"), and [session] backend can switch
persistence to GreptimeDB as well (see the backend sections below). Without --session, each launch creates a
fresh unique session ID. History is restored for
model context on startup, while display projections are replayed in the TUI;
the model only sees the latest compaction summary and everything after it.
Legacy .json sessions are migrated on first load. --version (or -V) prints the exact
package build version without loading the workspace or configuration. Optional CLI overrides
are --base-url URL, --model MODEL, --profile PROFILE, --workspace PATH, --session NAME,
--fork SESSION, --at N, and --max-rounds N (tool-call rounds are unlimited by default;
the flag sets an explicit cap). --read-only applies the read-only role policy to the main
session (see "Read-only roles").
--profile selects a TOML profile and requires a TOML config. When config is
used, --base-url and --model are raw API wire-value overrides and take
precedence over the selected profile values.
e-agent --fork <session-id> [--at <n>] [PROMPT] starts a brand-new session whose
history is a verbatim copy of the source session up to a completed turn. The
source session is never modified. The fork keeps the source's history prefix up
to (and including) the last completed turn — an assistant message with no
pending tool calls, or a compaction — and drops anything after it
(background-completion notices, for example). --at <n> picks the fork point
explicitly: n is 1-based and inclusive, and it must land on a turn boundary,
otherwise the fork fails rather than cutting mid-turn. Without a completed turn
in the source, the fork fails.
The new session starts with a ForkedFrom marker entry recording the source id
and fork point. The marker is display/audit only: it is rendered as a dim notice
in the TUI and is never sent to the provider on the model wire. The forked
session has fresh token accounting (usage counters start at zero) and is
resumed or forked again like any other session. The new id is printed as
e-agent: forked session: <id>; --fork is mutually exclusive with --session
and cannot be combined with --at without --fork. JSONL, GreptimeDB, and
SQLite backends behave equivalently.
Slash commands in the TUI input line (the web UI's input exposes the same command set through its slash-command menu):
/compact— compress the context (manual compaction)./rename <标题>— rename the session./btw <问题>— fork a side subagent for the question./fork [N]— fork a new session from the last (or the N-th) completed turn boundary./undo— undo the most recent file operation (edit_file/write_file)./goal//goal set <目标>//goal pause|resume|clear— view the session's goal, create a new one (human-only), or pause/resume/clear the current one./help [命令]— show the command list, or per-command usage detail (/help fork); an unknown command name prints a hint back to the list./model <profile>— switch the session's model at runtime (not listed by bare/help; the profile must be resolvable from the config).
The TUI renders with a truecolor (24-bit) Solarized Light palette; terminals without truecolor support may render colors incorrectly.
Keys in the TUI: Esc always leaves the current view — it detaches from a
subagent view, closes the tasks panel, or (at the plain idle prompt) quits
the app; it never cancels a turn. Ctrl-C cancels the in-flight turn and quits
at the idle prompt. On exit, e-agent prints e-agent --session <id> so the
session can be resumed from the same workspace.
The TUI sets the terminal title (which Zellij adopts as its pane title) to
e-agent — <title>, where <title> is derived from the first real user
message in session history (trimmed, control-safe, ≤40 Unicode chars). An
empty/new session shows the session id until the first prompt is submitted.
The title is never persisted or used as a session key. Users who prefer an
explicit label can override it with zellij action rename-pane "<custom>" at any time — Zellij's own title takes precedence.
The built-in local tool set always includes the capability-relative file tools
read_file, write_file, and edit_file, plus bash,
get_background_tasks, and cancel_background_task. Main-agent sessions also
register delegate; delegated subagents do not, which caps delegation depth at
web_searchis registered for either kind of session only whenEXA_API_KEYis set to a non-whitespace value, or when[web_search]setsapi_key_file/api_key_envin the config (process env wins). It searches public documentation and code examples through the Exa Context API. OpenCode's own Exa configuration is not inherited. Configured local MCP servers may add their tools to the main agent separately. The three file tools use a capability-relative directory rooted at the canonical workspace (the current directory by default). At startup, e-agent loads a non-emptyAGENTS.mdfrom that workspace root into the system context for both the main agent and delegated subagents. It does not search parent or nested directories, and the instructions are not persisted in sessions.
Skills are loaded from two directories, merged by name, and injected into the main agent's context (subagents do not inherit them):
- Global skills:
$XDG_CONFIG_HOME/e-agent/skills/<name>/SKILL.md(typically~/.config/e-agent/skills/...), or$HOME/.config/e-agent/skills/...whenXDG_CONFIG_HOMEis unset — matchingConfig::config_dir(). - Workspace skills:
.e-agent/skills/<name>/SKILL.mdrelative to the canonical workspace root.
At startup, the main agent scans both directories once (a fixed snapshot, no
hot reload) and injects a brief index — never the bodies — into its system
context: each skill becomes one - **name**: description — /absolute/path/SKILL.md
line, sorted by name. name/description come from the SKILL.md's leading
--- frontmatter (single-line name: / description: fields); missing,
unterminated or field-less frontmatter falls back to the directory name / a
fixed short phrase. When a skill's directory name exists in both, the
workspace version fully replaces the global one. The model opens a skill
with the existing read_file tool: workspace skills through the workspace
capability, global skills through an extra read-only capability rooted at the
global skills directory that only the main session's file tools carry
(subagents and btw forks never see it). The injection order is: main role
template (agents/main.md) → workspace AGENTS.md → skills → MCP server
instructions. Missing directories, non-directory entries, and symlinked skill
directories / SKILL.md files are silently skipped; the global skills root
must itself be a real directory, not a symlink.
read_file pages long files by 1-indexed lines (optional offset/limit,
default 2000 lines per read, capped at 64 KiB) and prints a continuation hint
when more lines remain.
Tool calls and their results are displayed as they happen; the final answer
ends the turn.
The workspace and configured external capabilities constrain only the agent's file tools. Relative paths use the workspace; absolute paths require an authorized external root, matched by the configured logical destination (the canonical source backs the capability; the input path is never canonicalized). Readable roots permit reads, while writable roots permit read/write/edit. For a writable operation the most-specific logical winner decides: a read-only winner (e.g. a read-only child under a writable parent) rejects the operation outright rather than falling back to a broader writable root, and an exact read-only file denies only itself — its siblings follow the parent's own policy. Exact-file roots do not grant authority to siblings. Directory capabilities permit symlinks that remain inside their root and reject symlink escapes. Exact-file capabilities use a durable handle opened at startup: if the trusted host renames or replaces that pathname, file tools still address the original inode, while Bash resolves and mounts the pathname on each invocation. The trusted host should keep configured roots stable for the run. Delegated custom workspaces must be canonical directories inside the startup workspace or an authorized writable external directory; read-only and exact-file capabilities cannot be rerooted, configured alias destinations never extend reroot, and when the sandbox is enabled a read-only workspace is not writable provenance either — reroot then derives only from writable canonical directory capabilities (an explicit writable canonical child of a read-only workspace still reroots through its external capability). Capabilities do not constrain hardlink inode origins or hardlink aliases to the protected project policy file, do not make read/edit sequences atomic, and are not a process sandbox.
bash runs /bin/bash -lc with the workspace as its current directory. By
default it is not sandboxed: commands can access files outside the workspace,
environment variables, and the network. On Unix, timed-out commands are killed
as a process group; on non-Unix platforms only the direct child is killed.
stdout and stderr are each captured up to 64 KiB, then drained and marked as
truncated.
Optional shell restriction is available when [sandbox] enabled = true is set.
On Linux/macOS this uses bubblewrap (bwrap) as described below. On Windows,
the write-sandbox MVP is an accident-prevention mechanism, using a restricted
primary token and stable synthetic capability ACEs. It restricts writes to the
workspace (only when workspace_writable = true) plus explicit
writable_paths; workspace_writable = false grants no workspace write ACE,
and readable_paths never grants write access. TEMP/TMP, HOME, Cargo/NuGet, and
engine caches are not implicitly writable.
The Windows MVP is not complete filesystem isolation: it does not restrict
reads or network access, and network = false is rejected before launch. Except
for the explicitly stripped credential variables (EXA_API_KEY,
OPENAI_API_KEY, ANTHROPIC_API_KEY, DEEPSEEK_API_KEY, MOONSHOT_API_KEY,
and KIMI_API_KEY), parent-process environment variables remain visible to the
child process; this is not read or secret isolation. Locations already writable
through Everyone or the current logon SID, including
some public locations, may remain writable. Only existing canonical directory paths on
fixed local NTFS volumes are accepted. UNC/device paths, non-directory roots,
roots that are symlinks/reparse points, NULL DACLs, and case-sensitive roots
are rejected. Before the first installation (or a versioned ACE upgrade), every
write root that needs its complete inheritable capability ACE is scanned without
following links. Hard-linked file descendants whose aliases all stay inside the
write roots are allowed; a hardlink that aliases a file outside the write roots
(out-of-bounds) is rejected with every alias path listed. Nested
symlink/reparse point descendants are unsupported and rejected. Once the exact current-version ACE is
installed, later commands skip both ACL propagation and that root's full-tree
scan. The ACE grants create/overwrite plus child rename/delete/atomic replace
through FILE_DELETE_CHILD inherited by directories, without granting DELETE
on the write root itself. Concurrent path replacement races (TOCTOU) remain a
risk. In practice this makes cargo's link_or_copy (which hardlinks cached
files, e.g. from ~/.cargo/registry/src, into the build target directory)
work when every alias stays inside the write roots — for example a writable
workspace plus ~/.cargo/registry as a narrowed write root instead of the
broader ~/.cargo. A hardlink that would alias a file outside the write roots
is still rejected with all alias paths listed, so align the write roots with
the locations the build actually shares. Windows shell
execution with protected Git metadata (protect_git = true, used by delegated
subagents/fixers) is unsupported and fails closed before token or ACL changes
with actionable guidance in the error message; there is no .git ACL
carve-out. A role template can opt out per role with protect_git = false in
its frontmatter (see "Role frontmatter: protect_git" above), restoring a
working shell for that role under the Windows sandbox.
Synthetic capability allow ACEs persist after successful execution. If adding an ACE to a later root fails, ACEs already added to earlier roots may also remain; the process is not started and no whole-DACL rollback is attempted. These synthetic SID ACEs are inert for ordinary tokens that do not contain the SID, but administrators may need to remove them manually. Cancellation, timeout, and running-task registry teardown currently terminate only the top-level process. Descendants may keep running with the capability and continue writing the allowed roots. Atomic assignment to a Job Object, with process-tree termination on cancellation, timeout, and registry teardown, is a future lifecycle enhancement.
On Linux/macOS every bash call—main agent and subagents alike—is wrapped in
bwrap: system directories are mounted read-only, the workspace is mounted
read-write (workspace_writable = false makes it read-only), /tmp and /home
are fresh tmpfs, PID/IPC/UTS namespaces are unshared, and TIOCSTI is blocked via
--new-session. Main-agent Bash gets a fresh writable private /tmp; delegated
subagent Bash gets an empty private read-only /tmp, so cross-command artifacts
belong in the workspace. Network stays available by default; network = false unshares
it. When the host uses systemd-resolved, the stub resolver at
/run/systemd/resolve is mounted read-only so DNS resolution via the symlinked
/etc/resolv.conf works inside the sandbox. The restriction constrains the
spawned command, not the agent process itself. It is best-effort: bwrap is not
setuid, the host network is shared by default, and environment variables of the
parent process other than the stripped credential names (EXA_API_KEY,
OPENAI_API_KEY, ANTHROPIC_API_KEY, DEEPSEEK_API_KEY, MOONSHOT_API_KEY,
KIMI_API_KEY) remain visible.
On Linux/macOS, the workspace .git entry depends on the role. The main agent
leaves it writable for repository operations. Delegated subagents/fixers bind
<workspace>/.git read-only over itself after writable mounts. This covers only
the workspace .git entry, not external object stores or other repositories.
Background commands inherit the parent's role.
[code_mode] enabled = true in the user-level config (a project .e-agent/config.toml cannot carry it) registers the experimental run_rust tool for the main agent only — subagents never inherit it; read-only main sessions get it only when a [sandbox] policy exists to narrow. It compiles one model-supplied Rust source (edition 2021, std only, ≤ 128 KiB) and runs it, both stages in a forced Linux bubblewrap sandbox:
- Inherits the resolved
[sandbox]policy: workspace perworkspace_writable, configured paths/mounts keep their semantics,.gitalways read-only, network always off, minimal PATH/locale env (credentials never set). Anonymous/tmpwrites land in a private 0700 scratch (<tmp>/e-agent-run-rust-*) mounted as the sandbox/tmp, removed only after confirmed teardown (bwrap reaped + process group gone) by descriptor-relativeO_NOFOLLOWremoval (chmod only ever targets pinned directory fds; the final unlink verifies the created inode identity); on any teardown uncertainty the scratch is retained/quarantined and reported, never force-deleted. Hostile swaps or chmod-000 trees can fail or retry cleanup but never follow or mutate anything outside it; authorized real-path writes land on the host. - The compile stage mounts the host rustc/rustup toolchain and runs with its PATH/RUSTUP_HOME; the run stage gets PATH
/bin:/usr/binand no toolchain mounts added by run_rust, so Cargo/toolchain is not supplied on the run-stage PATH/mounts (the prebuilt/tmp/runnerand/tmp/mainstill run on system runtime libraries; no claim against a user-supplied cargo binary invoked by explicit path). No Cargo/crates, stdin, background/daemon execution, or custom env/mount/cwd/timeout; 60-second compile / 30-second run timeouts kill the group, reap, bound the drain, and confirm the group is gone before removing the scratch (uncertain teardown retains/quarantines it); output capped at 24 KiB head + 8 KiB tail. The run status is an in-sandbox wrapper over a private inherited socketpair (dumpable-off + CLOEXEC before exec, so no descendant can forge; exactly one packet then EOF, gated on outer bwrap exit 0), so an explicitprocess::exit(134)is never misreported as a signal; the result reports the source SHA-256, the stable commands, both stage statuses and captured output — the source text is not repeated. - Enabling
[code_mode]fails closed at startup without Linux+bwrap+rustc; experimental, Linux-only, main-agent-only, no sandbox-escape model claimed beyond the exact bwrap policy above.
Every web_search query is disclosed to Exa, a third party. Never include
credentials, tokens, private repository contents, customer data, personal data,
private issue text, or internal URLs. Returned text is untrusted web content:
it may contain prompt injection, insecure code, or false claims. The tool does
not open links, execute returned code, or fetch arbitrary URLs. This guidance
is not a sandbox and cannot guarantee that a model will not disclose sensitive
context.
The bash tool accepts background: true to run a command without blocking.
Bash tasks and subagents share one running-task registry with no built-in
concurrency limit. Each bash task starts a process; subagents run as independent
SessionRunner tasks on the shared Tokio runtime. Callers remain responsible for
resource use. Background bash has
the same workspace cwd, process-group handling, and per-stream 64 KiB output
limit as foreground Bash, with a 30-minute timeout.
Completions surface as a TUI notification while a
turn is active, and the complete output is injected as a clearly labelled user
message at the next model call — whether that is the next round of an active
turn or, if the agent is idle, a new turn started by that delivery. F2 toggles
a tasks panel listing what is running. In
one-shot mode a still-running task is reported before exit;
the process then best-effort terminates its process group. Uninjected background
results are not persisted in sessions.
A FinishWhenIdle session (a delegated subagent, or a one-shot CLI session)
that finished its work waits indefinitely for its blocking background tasks
before finalizing. Each completion is injected and acknowledged, allowing the
model to continue in a follow-up turn. Detached tasks do not block finalization
and may outlive the session.
The delegate tool spawns a subagent with a fresh context to work on a
self-contained task. Use it for subtasks whose intermediate steps (searching,
reading many files, focused edits) would clutter the main context.
Each subagent runs as an independent SessionRunner task on the shared Tokio
runtime with an empty history. Its isolated Agent state and event log are
exposed through a SessionHandle, while the parent's running-task registry
remains shared so nested background bash commands stay visible and report
completion to the parent. It gets the same builtins as its parent: file/bash
tools and, when
configured, web_search; no MCP tools and no delegate itself, so delegation
depth is capped at 1 by construction. Tool rounds are unlimited.
By default (background omitted or true) the subagent runs without blocking;
the immediate tool result includes both the background task number and subagent
session ID, and its completion includes that same session ID when delivered as
a background task completion (waking an idle agent). Pass background: false
for sync mode, which waits without a fixed time ceiling and returns the completed
answer with the subagent session ID directly. Sync cancellation, failure, or
closure returns an error beginning with subagent session: <id>.
A running background subagent can be watched live in the TUI: open the tasks panel with F2, select it with Up/Down, and press Enter to attach. The attached view replays everything the subagent has done so far and then follows its stream (text, tool calls, results) with the same rendering as the main view; the footer shows the task and whether it is still running. Esc detaches back to the main view (it never cancels the main turn), and the subagent keeps running after detach. While attached, prompts can be queued and an in-flight turn can be cancelled; bash tasks have no event stream and cannot be attached.
Use /compact in the TUI or --repl to manually summarize earlier history.
The agent summarizes everything before the current turn and appends a
compaction entry (summary plus the retained turn) to the append-only session
log; earlier messages stay persisted and visible in the TUI. Subsequent model
calls use the latest summary plus everything after it. When a configured model
profile provides a context window, the agent also compacts automatically at
80% usage.
Every session can carry at most one current goal — a minimal persistence
layer (a deliberate subset of a DSH-style goal system, no scheduler/rounds/
verifier). Fields: id, revision, objective, success_criteria,
status (active|paused|blocked|completed), progress, evidence,
blocked_reason.
- Human-only creation:
/goal set <objective>(TUI/REPL/web), orPOST /api/sessions/{id}/goalwith{"action":"set","objective":…}. Creation is rejected while a non-completed goal exists; a completed or cleared goal frees the slot. The model never creates goals. - Model updates: the
get_goalandupdate_goaltools read the current goal and apply transitions (progress,pause,resume,block,complete,clear) under anid+revisionCAS;get_goalreturns the FULL machine-usable snapshot (all fields as JSON), while the provider context keeps a short ≤400-char projection.update_goalcan replacesuccess_criteriaand appendevidence(both must be string arrays).completerequires non-emptyevidencein the SAME call — pure analysis may use an explicitunverified: <analysis>string; a completed goal keeps its evidence.resumeunblocks paused OR blocked goals (clearingblocked_reason). - Append-only persistence: every change appends a complete snapshot as a
SessionEntry::GoalUpdated(goal: nullis the clear tombstone). The latest snapshot wins on resume (fold) and survives compaction: a short projection is prepended to every provider context, so the goal is never lost across compaction/resume/restart. After a clear, the context injects an explicitnone (cleared)override so a stale compaction summary can never re-introduce the old goal. - Forks inherit:
--fork//fork/btw forks copy the source prefix up to the fork boundary, including goal updates before it; the forked session folds the newest snapshot naturally. - Subagent isolation: subagents get the goal tools but their runner applies them against the subagent's own (usually empty) goal state — a subagent can never take a mutable reference to its parent's goal.
- UI: the TUI and web UI render goal updates as a notice line plus a
fixed GoalBar (
🎯 [status] objective); the UI shows evidence/unverifiedonly, never a claim of independent verification.
Non-goals (deliberately out of scope): todo/plan/workflow stages, auto goal rounds / max rounds, deadlines, reminders, goal DAGs / multiple goals per session, and a verifier agent.
Sessions persist entries losslessly; only oversized provider-request fields are
shown as a UTF-8-safe head/tail projection with a read_output ref. New refs
are durable direct numeric references: eout1.<entry-id>.<field-code> (for
example eout1.12.t). They persist across restart because they name a durable
entry in the current session: JSONL uses its 0-based nonblank entry ordinal;
SQLite and Greptime use logical session-local seq and read that seq's latest
row. Field codes are b background output, t tool content, n notice text,
a assistant content, u user content, s compaction summary, and r
compaction retained data. The runner always binds a direct ref to its current
store, workspace, and session; a ref contains no backend or session selector.
Historical self-contained eout1.<base64url JSON> refs remain accepted. Their
location/field/size JSON is parsed directly and any old m member is ignored,
so persisted history containing old long refs remains readable.
read_output is always available and takes ref, byte offset (default 0),
and limit (default 12 KiB, maximum 32 KiB). It returns a UTF-8-safe JSON page
with offsets, full-field SHA-256, and next_offset; the newest read_output
tool result is kept full in the next provider request so the requested page is
actually visible to the model.
Non-goal (this stage): the total context-window estimator that degrades many medium messages, and per-tool output-cap expansion for producers that already discard bytes (bash/MCP output caps stay as they are — persisted originals are the precondition for exact retrieval). Bounding only applies to fields that are certainly already persisted before replay.
e-agent --serve (alias: e-agent web) starts a headless HTTP server that
serves the web UI at / and a token-authenticated /api/* surface for
managing live sessions. It binds 127.0.0.1:8766 by default; --host and
--port (or -p) override:
e-agent web # http://127.0.0.1:8766
e-agent --serve --port 9000 # http://127.0.0.1:9000Every /api/* request must authenticate with the server token as
Authorization: Bearer <token> (or ?token=<token>, the query fallback for
EventSource-style clients that cannot set headers). The token is generated
once at first start — 32 random bytes, base64url, written with mode 0600 to
$XDG_STATE_HOME/e-agent/server.token (falling back to
~/.local/state/e-agent/server.token when XDG_STATE_HOME is unset) — and
reused across restarts so browser clients keep working. The startup log
prints the token and the token file path.
The /api surface (JSON except for the SSE endpoint):
| Method | Path | Semantics |
|---|---|---|
| GET | /api/sessions |
list sessions |
| POST | /api/sessions |
create a session |
| GET | /api/sessions/{id}/events |
SSE: snapshot, then live events |
| GET | /api/sessions/{id}/history |
segmented history (head or older) |
| GET | /api/sessions/{id}/summary |
per-turn summary cache (desktop pet) |
| POST | /api/sessions/{id}/prompt |
queue a prompt |
| POST | /api/sessions/{id}/btw |
fork into a persistent subagent |
| GET | /api/sessions/{id}/fork-candidates |
turn boundaries to fork at |
| POST | /api/sessions/{id}/fork |
fork at a turn boundary into a fork-… session |
| POST | /api/sessions/{id}/cancel |
release: preempt the in-flight turn without terminating the session (with no queued messages a WaitForInput session returns to Idle and stays usable) |
| POST | /api/sessions/{id}/compact |
request compaction |
| GET | /api/models |
switchable model profile names |
| GET | /api/pet/config |
live desktop pet sprite settings |
| GET | /api/pet/sprite |
configured local sprite-sheet bytes |
| POST | /api/sessions/{id}/model |
switch the session's model at runtime |
| POST | /api/sessions/{id}/undo |
undo the most recent file operation |
| PUT | /api/sessions/{id}/title |
rename a session |
| PUT | /api/sessions/{id}/pin |
pin a session |
| PUT | /api/sessions/{id}/archive |
archive a session |
| DELETE | /api/sessions/{id} |
cancel + remove from the registry |
| GET | /api/tasks |
running background tasks, all sessions |
| DELETE | /api/sessions/{id}/tasks/{task_id} |
cancel one background task |
| GET | /api/sessions/{id}/tasks/{task_id}/output |
full output of a running bash task |
SSE connections are kept alive with 15-second heartbeat pings. Ctrl-C shuts the server down gracefully: SSE streams self-close on the shutdown signal and in-flight requests get a hard 2-second drain deadline before the process force-exits; a second Ctrl-C kills it outright.
A known limitation of DELETE /api/sessions/{id} (pre-existing, tracked
separately): for a WaitForInput web session it can leave an idle runner
behind. cancel is a release that parks a WaitForInput session at Idle,
and any other holder of the live session (e.g. an in-flight request) keeps
the session's runner task alive past its removal from the registry — an
unaddressable idle runner that only ends when the last holder drops it or
the process exits. The transcript stays; a later resume still works.
The headless server (--serve / web) and the TUI watch the config files
(the global config.toml plus the project override
<workspace>/.e-agent/config.toml) and, when a file changes, re-read and
atomically swap the effective config. A change takes effect without
restarting the process:
[models]/[providers]/[roles]— new profile definitions are immediately available to the web/modelautocomplete andPOST /api/sessions/{id}/model/ TUI/modelswitches, and newly built sessions (webPOST /api/sessions, a fresh CLI session) start with the edited default and role routing.[mcp],[bash]timeout,[background]timeout — applied to sessions built after the reload.- Other sections are carried into the reloaded config but only take effect where a runtime read exists.
A reload that fails to parse or resolve (a typo, a missing key file, a
chatgpt-routed profile without a login) is rejected and logged; the
previous config stays, so editing the config never breaks a running server.
Deliberately NOT hot-reloaded (restart required):
[sandbox]— workspace roots and file capabilities are wired at startup.[session]backend — stores are connected at startup.[web_search]key changes — the key is injected into the process env once at startup (std::env::set_varis only safe single-threaded).AGENTS.md/ skills instructions and existing sessions' models (existing sessions keep their model until a/modelswitch).
e-agent can connect to local MCP servers over stdio and expose their tools
to the model with a <server>_<tool> name prefix. Servers are declared in
the same TOML config file as model profiles
($XDG_CONFIG_HOME/e-agent/config.toml, falling back to
~/.config/e-agent/config.toml):
[mcp.engram]
command = ["/path/to/engram", "mcp", "--tools=agent"]
enabled = true # optional, default true
# cwd = "/other/dir" # optional, defaults to the workspace root
# env = { KEY = "value" }A project-level <workspace>/.e-agent/config.toml [mcp] section overlays
these servers by name: a project server replaces the same-named global
server, and servers only the global config defines keep their definitions.
Because each entry spawns command, both the global and the project config
are trusted inputs — only open workspaces you trust, since opening a repo
executes the commands its .e-agent/config.toml declares. A global
[mcp."<name>"] enabled = false is a kill switch a project file cannot
re-enable: the merged config keeps the disabled global entry, so a
same-named project server is skipped (connect_all skips disabled servers).
Project MCP servers apply to subagents too — tools are built from the unified
effective config, and subagents inherit the workspace's MCP toolset.
Each entry spawns command with the workspace as its current directory
(override with cwd), plus optional env. Servers are connected one at a
time at startup; a server that fails to start is skipped with a warning and
does not abort the session. Server-provided instructions (from the MCP
initialize response) are prepended to the model context as a system
message on every call, but are not persisted in sessions. Only tools are
supported — no resources, prompts, or server-initiated notifications.
OPENAI_API_KEY— required in environment-variable mode.OPENAI_BASE_URL— API base URL, default:https://api.openai.com/v1.OPENAI_MODEL— model name, default:gpt-4o-mini.EXA_API_KEY— optional; a non-whitespace value enablesweb_searchthrough Exa Context.RUST_BACKTRACE=1— optional; appends a backtrace to ordinaryanyhowerror chains (and enables their full Rust verbosity). Rust panics always print a forced stack regardless of this setting.
A Rust panic prints its thread, location, and forced stack to stderr while
omitting the panic payload. It also writes a private crash report to
$XDG_STATE_HOME/e-agent/crash/latest.log, or, when XDG_STATE_HOME is unset,
~/.config/e-agent/crash/latest.log. On the next startup, e-agent reports the
previous crash and renames latest.log to previous.log.
The global panic hook also observes Tokio worker and subagent task panics. Such
a panic may be caught by Tokio as a JoinError, so its diagnostic and report do
not by themselves mean the main TUI exited or that the application is fatal.
Panic reports are not guaranteed for SIGKILL, OOM termination, aborts,
segmentation faults, or a top-level Err return; those are not ordinary Rust
panics.
TOML config is optional. When present, it supplies the selected provider
credential and profile values instead of OPENAI_*; see Run. A
provider using api_key_env reads the variable named by that field (for
example, KIMI_API_KEY).
Two entrances attach images to the conversation, both stored in a global
content-addressed cache (files named by SHA-256 hex, deduplicated across
sessions) at $XDG_STATE_HOME/e-agent/images/, falling back to
~/.config/e-agent/images/ — the same base the crash directory uses:
read_imagetool (agent entrance): the model reads an image file (png/jpeg/webp/gif, up to 10 MiB; capability-path policy likeread_file). The bytes are stored once under their hash; the tool result is structured — a text summary plus the image reference — and both ride on the canonicalToolmessage (no synthetic user message is created). Base64 never enters the scrollback or the session file.- REPL
/image <path>(human entrance, REPL mode only): attaches the image to the next prompt; the placeholder line[image attached: <path>]is included in the prompt text.
Sessions persist only ImageRef { hash, mime } references — never base64.
At send time the wire re-reads the file and builds the provider format:
image_url parts (an object) for OpenAI-compatible chat, input_image parts
(a bare-string image_url) for ChatGPT Responses. An image-bearing tool
result is encoded natively on both wires: the chat wire emits all tool
results as plain role:"tool" text and then appends at most one aggregated
temporary user image message per consecutive tool batch (wire-only, never
persisted), while the Responses wire emits a function_call_output whose
output is an array of input_text + input_image parts. A missing cache
file degrades to a [image missing: <hash>] text placeholder instead of
failing.
A model only receives images when its profile sets vision = true (see
Run). On a non-vision model the request copy strips image parts from
user and tool messages with a text-degradation note, while the persisted
history keeps them — switching back to a vision model restores the images on
the next request. An explicit /image prompt on a non-vision model fails
with a clear model X does not support image input error before any request
is sent.
Errors print their causal chain (for example cannot decode provider response: error decoding response body: ...), and provider HTTP failures
include a preview of the response body. Provider requests time out after
600 seconds in total, including the complete streaming response body.
A local SQLite session storage backend is available and compiled in by
default (sqlite is a default cargo feature). It replaces the JSONL file
backend for session persistence; select it via the TOML config:
[session]
backend = "sqlite"
path = "/home/you/.local/share/e-agent/sessions.db"path is a path to a SQLite-compatible database file (:memory: works for
tests); it is used as written — ~ is not expanded, and a relative path
resolves against the process working directory, so an absolute path is
recommended. The parent directory is created on first connect. Sessions of
different workspaces are isolated by a workspace_id derived from the
canonical workspace root, so one database file can serve many workspaces.
The database keeps three tables:
session_entries— the append-only transcript log, primary-keyed on(workspace_id, session_id, seq, event_time_us)with the same entry kinds and ordering semantics as the other backends.running_tasks— in-flight background bash/delegate bookkeeping; a resumed session finds tasks killed by a previous process.sessions— a per-session metadata audit log (created_at/model/role/ title/pinned/archived/writer) backing the web UI's list/rename/pin/archive views.
Connections use PRAGMA journal_mode=WAL and busy_timeout=5000, so
processes sharing one file wait out short writer locks instead of failing.
The strictly monotonic per-process event_time_us keeps seq continuity
identical to the JSONL/GreptimeDB backends. Like GreptimeDB, SQLite has no
automatic migration from JSONL and no cross-backend migration. SQLite is
the default backend since 0.1.1+ (absent [session] resolves to
.e-agent/sessions.db); JSONL stays selectable for legacy workspaces.
An optional GreptimeDB-backed session storage backend can be selected at
runtime via the TOML config file. It replaces the JSONL file backend for
session persistence; background-task bookkeeping lives in the backend's
running_tasks table (JSONL sidecar files are only used by the JSONL
backend).
cargo build --features greptime[session]
backend = "greptime"
conn = "host=127.0.0.1 port=4002 dbname=public"The connection string is passed directly to tokio-postgres. Default port
for GreptimeDB's pg-wire protocol is 4002. The server must be running
before e-agent starts (no auto-discovery or embedded mode).
CREATE TABLE IF NOT EXISTS session_entries (
workspace_id STRING NOT NULL,
session_id STRING NOT NULL,
seq BIGINT NOT NULL,
event_time TIMESTAMP(9) NOT NULL TIME INDEX,
entry_kind STRING NOT NULL,
payload STRING NOT NULL,
schema_version INT NOT NULL DEFAULT 1,
is_error BOOLEAN NOT NULL DEFAULT FALSE,
appended_at TIMESTAMP(9) NOT NULL DEFAULT now(),
PRIMARY KEY (workspace_id, session_id)
) WITH (append_mode = 'true', sst_format = 'flat')workspace_id is derived from the canonical workspace root path, so sessions
with the same name in different workspaces remain isolated.
- GreptimeDB server must be running before e-agent starts
tokio-postgresneeds thewith-chrono-0_4feature for TIMESTAMP params (already included in thegreptimefeature)event_timeis wall-clock time with microsecond precision. Within a single process, the monotonicnext_event_time_usatomic guarantees strict ordering (same-µs or backward clock → prev+1). Dedup: per seq, only rows with the latestevent_timeare retained; at the same maxevent_time, identical deserialisedSessionEntryvalues are folded and divergent ones cause a hard error. Canonical conversation order is the winningevent_time ASC, withseq ASConly as a tie-breaker; seq remains identity and continuity metadata.- No transactions — atomicity is per multi-row INSERT statement
- Concurrent-write detection is fail-closed, point-in-time, not
atomic: GreptimeDB's pg-wire has no transactions and no
conditional-write/lease mechanism, so the max-seq pre-check, the
INSERT, and the post-insert verification are separate statements, and
the post-insert verification is a point-in-time re-read. e-agent
verifies AFTER committing: every committed row in the written range is
re-read and must still be the physical winner for its seq (latest
event_time, identical duplicates folded); if a foreign writer superseded or diverged any row at verification time, the append fails with aconcurrent write conflicterror instead of silently latest-winning or advertising a receipt location the logical read path would not pick. Ambiguity that is visible at verification time is detected and reported — never hidden. But a foreign writer can still commit at the same seqs AFTER the verification query and BEFOREappend_locatedreturns; that post-verification race is irreducible without a server-side lease or conditional write (winner-at-return would require one). Exact physical receipt pinning still holds for every location this process verified before the race window closed. - Subagents each open their own database connection (bounded, fine for single-user agents)
| Operation | Query | Optimization |
|---|---|---|
| connect (find max seq) | SELECT MAX(seq) ... WHERE workspace_id=$1 AND session_id=$2 |
Full partition scan (acceptable, sessions are bounded) |
| load (all entries) | SELECT seq, event_time, payload ... ORDER BY event_time ASC, seq ASC |
App-side cross-version dedup (latest event_time per seq), then canonical winning event_time ASC, seq ASC order |
| append | Parameterized multi-row INSERT | Single statement, all-or-nothing per chunk |
A standalone binary for manual, incremental JSONL-to-GreptimeDB import. Only
appends entries that do not yet exist, and refuses to write when the existing
DB prefix diverges from the JSONL file. Re-runnable under idle target + strict
prefix conditions. Duplicates (identical-deserialised-SessionEntry retries
at the same max event_time) are folded; divergent duplicates at the same
event_time are rejected.
cargo build --features greptime --bin e-agent-import-jsonle-agent-import-jsonl --session <SESSION_ID> [--workspace <PATH>] [--conn <CONN>] \
[--dry-run]--session(required) — session name matching[a-zA-Z0-9_-]+.--workspace(default: current directory) — workspace root; canonicalised and used to derive theworkspace_idthat scopes the session.--conn(default: from config[session] backend = "greptime") — pg-wire connection string (e.g."host=127.0.0.1 port=4002 dbname=public").--dry-run— print the range that would be appended without inserting rows. NOTE: still connects to DB, which may execute CREATE TABLE.
- The tool requires the JSONL file at exactly
.e-agent/sessions/<id>.jsonlto exist (empty files are valid). Legacy.jsonformat is rejected. - The DB's seq values are verified to be strictly 0..N continuous before
planning (gaps or divergent-deserialised-
SessionEntryduplicates cause a hard error; identical deserialised entries are silently folded). - If the DB has more entries than the JSONL file, the tool errors out without writing — it will never truncate.
- If the existing DB prefix differs from the same-length prefix of the JSONL file, the tool reports the first divergent sequence number and refuses to write.
- When the prefix matches, the tool re-reads the DB immediately before writing (TOCTOU mitigation) to detect concurrent changes. If the DB changed, the tool errors out.
- After writing, the tool reloads the DB and verifies it matches the JSONL file exactly by length and per-entry content. Verification failure is reported as an error.
- Not safe for concurrent writers: GreptimeDB has no transactions, so a concurrent writer between the pre-write check and the INSERT can still race. Ensure the target session is idle during import.
- Partial commits: chunks of >9000 entries use separate INSERT statements. If the first chunk commits and the second fails, some entries are written. Fix the issue (e.g. network, JSONL source) and re-run.
- Duplicate handling: within a seq, older
event_timerows are silently overwritten by the latestevent_timerow. If the latestevent_timehas multiple physical rows (same microsecond), their payloads are deserialised and compared asSessionEntryvalues. Identical deserialised entries are folded silently; divergent entries cause a hard error — stop writers, inspect with SQL, and resolve manually.
# Dry-run to see what would be imported
e-agent-import-jsonl --session 20250331-120000-abc3 --dry-run
# Real import (normal append, prefix must match)
e-agent-import-jsonl --session 20250331-120000-abc3
Integration tests require a live GreptimeDB and opt-in via the GREPTIME_PG
environment variable:
GREPTIME_PG="host=127.0.0.1 port=4002 dbname=public" cargo test --features greptimeWithout GREPTIME_PG the integration tests skip with a message.
This is deliberately not a daemon, JSONL protocol, subagent
framework, permission framework, plugin host, generic provider/auth framework,
parallel tool executor, task scheduler, priority system, or concurrency pool.
It deliberately does not fetch provider/model catalogs (including
models.dev), generate configuration, cache provider metadata, or infer
context windows; TOML profiles are local static settings only. AGENTS.md
loading is workspace-root-only: there is no parent/nested discovery or merging.
Subagents exist but are deliberately minimal: no agent-to-agent messaging,
no delegation deeper than 1 level, and no process-level isolation yet
(subagents are runtime tasks, not subprocesses).
Cancellation is a release, not a termination: cancel preempts the
in-flight operation and never ends the session — the only hard termination
is DELETE /api/sessions/{id} or the tasks-panel cancel (which aborts a
subagent through its parent's background-task registry). Idle-runner
lifecycle management (idle timeouts / GC for parked sessions) is not in
scope.
Windows restricted-token MVP is only an accident-prevention write restriction:
no read isolation, network isolation, Job Object, AppContainer, broker/IPC,
ConPTY, dedicated user, protected-git shell execution, or bwrap-equivalent
filesystem view. Everyone/logon-SID writable public locations can remain
writable, synthetic capability ACEs persist, and cancellation currently
terminates only the top-level process.
run_rust (experimental [code_mode]) has no Cargo/crates, network access,
stdin, background or daemon execution, or custom env/mount/cwd/timeout;
Cargo/toolchain is not supplied on the run-stage PATH/mounts (compile-only
toolchain exposure, removed after the compile stage). A failed or uncertain
teardown retains/quarantines its scratch path rather than deleting it.
Workspace and configured-path writes are NOT blocked by design: they fully
inherit the resolved [sandbox] policy like bash. Main-agent-only, Linux-only,
opt-in.
It does speak MCP to local stdio servers (tools only), but it does NOT do
remote MCP over HTTP/SSE, MCP OAuth, MCP resources/prompts, server-initiated
notifications, listChanged refresh, server restart on crash, or concurrent
server initialization.
It has one model seam and one tool seam. Main-agent tool rounds are unlimited
unless --max-rounds sets an explicit cap; subagent tool rounds are unlimited.
Config hot reload is deliberately scoped: [models]/[providers]/[roles]
(and anything else read at session-build time) hot-reload in the server and
TUI via mtime polling with validate-before-swap, but [sandbox], the
[session] backend, and web-search key env injection stay startup-fixed and
require a restart; there is no reload HTTP endpoint, no config diffing, no
watch(1)/inotify, and no per-section partial reload (a bad edit is rejected
wholesale and the last good config is kept).
Reasoning-model reasoning_content is persisted in the session for
display/audit; it is never sent back to the API, except by an explicit
deepseek_compat = true thinking-mode tool-call profile (see Run).
Web search deliberately has no browser, crawler, URL fetch, citations, domain
filters, multiple providers or provider trait, retries, cache, background
search, or remote MCP support.
The web UI is a single self-contained HTML page, assembled from disk on
every request in dev builds and compiled into the binary via include_str!
in release builds: deliberately no frontend bundler/build pipeline, no
hashed asset routes, no HTTP compression, no runtime asset discovery, and no
KaTeX font packaging.
Workspace skills are deliberately flat <skills-dir>/<name>/SKILL.md only
(global skills from Config::config_dir()/skills/, workspace skills from
.e-agent/skills/): frontmatter disclosure is limited to single-line
name/description with no body injection, sanitizer, or caps; no remote
installation, registry, dependencies, hot reload, slash commands, on-demand
loading, config toggle, or recursive skill discovery. Global skills are
main-agent-only: subagents and btw forks neither inherit the index nor the
read capability. The same-name override is a full replacement, not a merge
or concatenation.
Session goals are a single-current-goal persistence layer only: no
todo/plan/workflow stages, no auto goal rounds / max rounds, no deadlines,
no reminders, no goal DAGs / multiple goals per session, no goal verifier
agent, and no scheduler/driver — the goal is a snapshot with a CAS, not a
task runner.
GreptimeDB-specific non-goals (when built with --features greptime):
- No automatic migration of existing JSONL sessions — existing JSONL sessions
are not migrated to GreptimeDB at startup; use
e-agent-import-jsonlfor manual incremental import - No automatic migration between backends — switching backends in config does not transfer session data
- No cross-workspace session sharing — sessions with the same name in different workspaces are fully isolated by workspace_id
- No transaction support — atomicity is per multi-row INSERT statement, not across statements
- No distributed/cluster deployment — standalone GreptimeDB only
- No generic storage abstraction for future third backends