Guidance for Claude Code when working in this repository.
See README.md for user-facing docs, docs/ARCH.md for architecture detail, and docs/SERVER.md for the HTTP server (aictl-server). This file is the compact reference for code changes.
cargo build # debug build (workspace, all members)
cargo build --release # release build
cargo run --bin aictl -- <args># run the CLI binary with arguments
cargo lint # clippy pedantic (alias in .cargo/config.toml; lints default-members — desktop excluded)
cargo fmt # format
cargo test # run tests across the workspaceFour-crate Cargo workspace; aictl-desktop is excluded from default-members so a bare cargo build / cargo lint / cargo test keeps working without Tauri's deps. CI builds the desktop separately on macOS only.
crates/aictl-core/— packageaictl-core, library crate (lib nameaictl_core). Hosts the agent loop, providers, tools, security, sessions, audit log, MCP/plugin/hook systems, and theaictl_core::ui::AgentUItrait that frontends implement. Does not link any terminal library; every side effect routes throughAgentUI(oraictl_core::ui::warn_globalfor runtime warnings).crates/aictl-cli/— packageaictl-cli, binary crate ([[bin]] name = "aictl"). Hosts the REPL, slash-command UI, status banner, and thePlainUI/InteractiveUIimpls ofAgentUI(crossterm + indicatif + termimad + rustyline live here). Re-exportsaictl-core's modules undercrate::*for legacy import paths.crates/aictl-server/— packageaictl-server, binary crate ([[bin]] name = "aictl-server"). OpenAI-compatible HTTP LLM proxy:POST /v1/chat/completions,POST /v1/completions,POST /v1/messages,GET /v1/models,GET /v1/stats,GET /healthz. Pure proxy — no agent loop, no tool dispatch, no agents/skills/sessions. The OpenAI-shaped routes translate to the engine'sVec<Message>and dispatch viaaictl_core::llm::call_<provider>.POST /v1/messagesis dual-mode (seemessages.rs): Anthropic models always pass throughmessages::passthroughverbatim toapi.anthropic.com/v1/messages(tool_use / tool_result blocks untouched, prompt caching / extended thinking /anthropic-betafeatures intact); non-Anthropic models are rejected with400 model_not_foundunlessAICTL_SERVER_MESSAGES_CROSS_PROVIDER=true, in which case they route throughmessages::translator— a per-provider HTTP round-trip (notaictl_core::llm::call_*— those use the engine's XML tool format) that translates the Anthropic request shape into OpenAI / Gemini / Ollama native shapes and back. The translator owns its own dispatch, nativetools[]/tool_calls[]survive the round-trip, and provider streams bridge to Anthropic's structured SSE event sequence (message_start/content_block_start/content_block_delta/content_block_stop/message_delta/message_stop) via state machines undermessages/translator/stream/. Unsupported Anthropic features (cache_control,thinking, PDF blocks, URL images on Gemini/Ollama) flow throughmessages::translator::feature_gatewithstrip/warn/rejectmodes (AICTL_SERVER_MESSAGES_FEATURE_GATE); GGUF / MLX are rejected on the cross-provider path. Reusesaictl_core::run::redact_outboundandaictl_core::security::detect_prompt_injectionon every path; passthrough logsgateway:anthropic, translation logsgateway:messages:<provider>, plusfeature_dropped:<provider>entries when fields are stripped. Master-key gate on every authenticated route;master_key::resolvereadsAICTL_SERVER_MASTER_KEYthroughkeys::get_secret(keyring-first, plain config fallback) and persists a generated key viakeys::set_secreton first launch — so CLI-side/keyslock/unlock cycles round-trip the entry without restarting the server. axum 0.8 + tower-http live here only — they never enteraictl-core. See docs/SERVER.md for the full reference.crates/aictl-desktop/— packageaictl-desktop, Tauri-based macOS desktop app (work-in-progress, unreleased). Excluded fromdefault-members; only built explicitly withcargo build -p aictl-desktop. Reuses theaictl-coreengine; no functional drift from the CLI.
Cargo features (gguf, mlx, redaction-ner) live on the aictl-core crate; the CLI and server declare them as aictl-core/<feature> passthroughs.
Submodule trees: llm/ (providers) and tools/ (tool impls) live under crates/aictl-core/src/. commands/ (slash-command handlers) lives under crates/aictl-cli/src/.
crates/aictl-cli/src/main.rs— CLI (clap), config + security + session init, agent loop driver, single-shot vs REPLcrates/aictl-core/src/run.rs—run_agent_turnloop, tool-call dispatch, outbound redaction, stream suspend wiring; also exposesProvider,with_esc_cancel,Interrupted,build_system_prompt,run_agent_singleagents.rs(+agents/remote.rs) — agent prompts in~/.aictl/agents/(per-user catalogue) plus project-local overrides at<cwd>/.aictl/agents/or<cwd>/.claude/agents/as a legacy fallback (the presence of.aictl/skips.claude/entirely — seeconfig::local_config_root). Both bare<name>and<name>.mdfilenames are accepted (the.mdconvention matches the remote catalogue and the project-local directories).read_agentresolves local-first;list_agentsmerges both with local entries winning on name collision. EachAgentEntrycarries anOrigin(Global/Local/LocalClaude) that drives the badge in/agent,--list-agents, and the entry-awaredelete_agent_entry/save_agent_entryso menu actions land at the correct file. Loaded agent is appended to the system prompt. Optional YAML frontmatter (name,description,source,category) —source: aictl-officialrenders an[official]badge alongside the origin tag.agents/remote.rsfetches the live catalogue from.aictl/agents/in the project repo via GitHub's trees API and pulls a single.mdon demand (REPL browse entry or--pull-agent <name>+--force) — pulled files always land in the per-user~/.aictl/agents/. The frontmatter is stripped before the body is injected into the system prompt.audit.rs— per-session JSONL tool-call log under~/.aictl/audit/<session-id>.set_file_override(path)(wired from--audit-file <PATH>) redirects logging to an explicit file and force-enables the subsystem so single-shot runs (no session id) can capture an audit trail.commands.rs+commands/— slash commands (/agent,/balance,/behavior,/clear,/compact,/config,/context,/copy,/exit,/gguf,/help,/history,/hooks,/info,/keys,/mcp,/memory,/mlx,/model,/ping,/plugins,/remember,/retry,/roadmap,/security,/session,/skills,/stats,/tools,/undo,/uninstall,/update,/version); any other/<name>falls through to a user-defined skill lookup.config.rs—~/.aictl/configloader (OnceLock<RwLock<HashMap>>), constants,load_prompt_file,local_config_root(resolves<cwd>/.aictl/or the legacy<cwd>/.claude/root used by agents and skills for project-local overrides)keys.rs— keyring-backed API key storage with plain-text fallback; useget_secret(name)notconfig_getfor keyssecurity.rs—SecurityPolicy: shell/path validation, CWD jail, env scrub, output sanitization, prompt-injection guardsecurity/redaction.rs(+redaction/ner.rs) — redactor used at two seams: network-boundary (redact_outboundinrun.rs) and persistence-boundary (redact_for_persistence, called fromsession::save_messagesand the REPL'sadd_redacted_history). Three layers — A: regex, B: entropy, C: optional NER.redact_for_persistencetreatsBlockmode asRedact(the network call has already aborted; we still want placeholders on disk).memory.rs— long-term memory store at~/.aictl/memory.json. Two write seams: thesave_memorytool the agent calls when it spots a fact worth remembering, and the CLI/remember <fact>slash command. Reads happen inrun::build_system_promptviamemory::prompt_block, which appends a# Memorysection listing every saved fact.enabled()readsAICTL_MEMORY_ENABLED(defaulttrue);session::is_incognito()is the stronger kill-switch — when on,addreturnsDisabled,load_for_promptreturns empty, and the prompt block is suppressed so a temporary chat never leaks into the long-term store and never sees prior memories. List capped at 200 entries (MAX_ENTRIES); each entry capped at 1000 chars (MAX_ENTRY_LEN); over-cap writes drop the oldest entry first. The CLI surfaces management via/memory(toggle / view / delete one / clear all) and the non-interactive--list-memories/--remember <FACT>flags. The desktop app exposes the same surface under Settings → Memory via thememory_status/memory_set_enabled/memory_add/memory_remove/memory_clearTauri commands.session.rs— session persistence + incognito toggleskills.rs(+skills/remote.rs) —~/.aictl/skills/<name>/SKILL.mdCRUD + frontmatter parse, with project-local overrides at<cwd>/.aictl/skills/<name>/SKILL.mdor<cwd>/.claude/skills/<name>/SKILL.mdas a legacy fallback (same.aictl/>.claude/precedence as agents).findresolves local-first;listmerges both with local entries winning on name collision. EachSkillEntrycarries anOrigin(Global/Local/LocalClaude) that drives the badge in/skillsand--list-skillsand is consumed by the entry-awaredelete_entryso menu actions target the correct directory. Skills are one-turn-scoped markdown playbooks: for onerun::run_agent_turncall the skill body is concatenated ontomessages[0].content(not inserted as a separate System message — Anthropic/Gemini only keep the last System they see) and never written into session history. Invoked via/<skill-name>,--skill <name>, or the/skillsmenu.AICTL_SKILLS_DIRoverrides the per-user default directory (local overrides are unaffected). Optional YAML frontmatter (name,description,source,category) —source: aictl-officialrenders an[official]badge alongside the origin tag.skills/remote.rsfetches the live catalogue from.aictl/skills/<name>/SKILL.mdin the project repo via GitHub's trees API and pulls a single SKILL.md on demand (REPL browse entry or--pull-skill <name>+--force) — pulled directories always land in the per-user catalogue.stats.rs— usage stats under~/.aictl/statstools.rs+tools/— XML parsing, dispatch, duplicate guard, per-tool impls (35 tools, includingsave_memorywhich writes throughmemory::add,view_mapwhich the desktop app intercepts to render OpenStreetMap/Esri pins, anddraw_chartwhich the desktop app intercepts to render a Chart.js canvas that re-themes when the app theme flips). Tool names starting withmcp__route tomcp::call_tool; everything else unknown falls through toplugins::find()so user-installed plugin tools dispatch through the same gate. Duplicate-call key normalizes JSON bodies formcp__*calls so whitespace differences don't create distinct cache entries.hooks.rs— user-defined lifecycle hooks loaded from~/.aictl/hooks.json(override viaAICTL_HOOKS_FILE). Events:SessionStart,SessionEnd,UserPromptSubmit(can block or rewrite the prompt),PreToolUse(can block or pre-approve a tool),PostToolUse(observe + add context),Stop(after final answer),PreCompact,Notification. Each hook is{ matcher, command, timeout, enabled }; the matcher is a glob with*/?/|alternation against the tool name (or*for non-tool events). Hooks receive a JSON payload on stdin (event, session_id, cwd, tool, prompt, …) and may return JSON on stdout:{"decision":"block","reason":"..."}aborts the action;{"decision":"approve","reason":"..."}skips the user confirm;{"additionalContext":"..."}injects a<hook_context>block into the next turn;{"rewrittenPrompt":"..."}(UserPromptSubmit only) replaces the user message before the agent sees it. Plain-text stdout becomes additionalContext. Exit code2is short-hand forblockwith stderr as the reason. Hooks are harness behavior —--unrestricteddoes not bypass them./hooksREPL menu and--list-hooksflag manage the catalogue. Default per-hook timeout: 60s.mcp.rs(+mcp/{config,protocol,transport,stdio,http,sse}.rs) — Model Context Protocol client. Servers declared in~/.aictl/mcp.json(override viaAICTL_MCP_CONFIG) in a Claude Desktop-compatible shape. Stdio entries:{ command, args, env, enabled, timeout_secs }. Remote entries settransport: "http"(modern Streamable HTTP) or"sse"(legacy HTTP+SSE) and supply{ url, headers, enabled, timeout_secs }. Bothenvandheadersvalues support${keyring:NAME}substitution that pulls a secret fromkeys::get_secret(NAME)at parse time rather than checking it in. Themcp::transport::Transporttrait (boxed-future, object-safe viaArc<dyn Transport>) is the shared dispatch surface forStdioClient/HttpClient/SseClient— the call-site inmcp::call_tooldoesn't care which transport a server uses. JSON-RPC 2.0 framing is hand-rolled (no extra deps):init_with()parses the config, connects each enabled server in parallel, completes theinitializehandshake underAICTL_MCP_STARTUP_TIMEOUT(default 10s), callstools/list, stores the catalogue in aOnceLock<Vec<McpServer>>. Per-server failures land inServerState::Failed(reason)and never abort startup. Tools surface asmcp__<server>__<tool>; the catalogue (with input schemas) is appended to the system prompt byrun::build_system_prompt.tools.rs::execute_toolroutes any name starting withmcp__tomcp::call_tool, which canonicalizes the JSON body, sendstools/call, and concatenatescontent[]text blocks. Whole subsystem gated behindAICTL_MCP_ENABLED=true(default off) — third-party processes do not auto-spawn. Remote-transport gate:security::validate_mcp_urlenforces hostname allow/deny and HTTPS-by-default —AICTL_MCP_ALLOW_HOSTS=api.example.com,*.foo.com(whitelist; empty/unset = allow-any beyond the deny list),AICTL_MCP_DENY_HOSTS=bad.example.com(blacklist; always wins),AICTL_MCP_ALLOW_HTTP=true(opt-in for plaintexthttp://). The check runs at config-parse time so a denied URL fails fast, and the HTTP/SSE clients re-validate at every dispatch as defense-in-depth — outbound network calls otherwise bypass the CWD jail.AICTL_MCP_DENY_SERVERS=foo,barblanket-blocks servers at the tool-call security gate;AICTL_MCP_DISABLED=fooskips them at init time.--mcp-server <name>restricts a single process to one server without persisting the disable list.mcp::shutdown()runs on every exit path; stdioChildspawns withkill_on_drop(true)and the SSE reader task is aborted onDropas a backstop.--list-mcpprints the catalogue;/mcpis the REPL menu. Reference smoke server atexamples/mcp/tiny_add/server.pyand example config atexamples/mcp.json.plugins.rs— discovery + execution of user-installed plugin tools under~/.aictl/plugins/<name>/(override viaAICTL_PLUGINS_DIR). Each plugin pairs aplugin.tomlmanifest (name/description/entrypoint/optionalrequires_confirmation/timeout_secs/schema_hint) with an executable.init()walks the directory, parses the manifest with a hand-rolled mini-TOML parser (subset: strings, bools, ints, triple-quoted strings), validates the entrypoint stays inside the plugin dir (symlink-aware, rejects collisions with built-in tool names), and stores survivors.execute_pluginspawns the entrypoint directly (no shell), pipes the tool body in on stdin, returns stdout — or[exit N] <stderr>on non-zero exit. Pinned tosecurity::working_dir()withscrubbed_env()and the plugin's manifest timeout (falling back tosecurity::shell_timeout). The whole subsystem is gated behindAICTL_PLUGINS_ENABLED=true(default off) — third-party code must not auto-load.--list-pluginsprints the catalogue;/pluginsis the REPL menu.crates/aictl-core/src/ui.rs—AgentUItrait,ToolApproval,ProgressHandle+ProgressBackend, theWarningSink/set_warning_sink/warn_globalglobal-warn surface. No terminal-library types in scope.crates/aictl-cli/src/ui.rs—PlainUI(single-shot, pipe-friendly) +InteractiveUI(REPL: termimad markdown, crossterm tool-confirm selector, indicatif progress backend, raw-mode Esc cancel listener).PlainUIcarries anOutputFormat(mddefault = pass through raw markdown source, streamed when stdout is a TTY;text= strip markdown markers viastrip_markdownregex pass and emit plain prose;json= emit a one-line{"answer", "model", "provider"}envelope on stdout). Stream chunks fortext/jsonare swallowed because formatting markers can split across deltas —show_answeris the single emission point on those paths.text/jsonalso suppress reasoning / auto-tool / tool-result chatter (jsonon stderr too) so stdout stays clean for piping. Re-exportsaictl_core::uitypes so legacycrate::ui::AgentUIpaths keep resolving.llm.rs+llm/—TokenUsage,MODELScatalog, provider calls (OpenAI, Anthropic, Gemini, Grok, Mistral, DeepSeek, Kimi, Z.ai, Ollama, GGUF, MLX).llm/gguf.rs::CATALOGandllm/mlx.rs::CATALOGexpose the curated starter lists (label / spec / size_label) the CLI's/gguf+/mlxmenus and the desktop's "Local Models" Settings tab both consume — single source of truth so the two frontends stay in sync.llm/balance.rsexposes per-provider credit/quota probes used by/balanceand--balance/--list-balances: real fetchers for DeepSeek (GET /user/balance) and Kimi (GET /v1/users/me/balance— base URL viaLLM_KIMI_BASE_URLfor the.cnendpoint); every other cloud provider returnsUnknownwith a billing-dashboard hint. Local providers are not probed. Whenaictl-serverrouting is active (AICTL_CLIENT_HOST+AICTL_CLIENT_MASTER_KEY),fetch_allshort-circuits per-provider probes and instead pulls the server's/v1/statsaggregate (per-provider rows surface asUnknownwith a hint that the server tracks dispatch counts, not upstream balances).llm/server_proxy.rs—aictl-serverupstream client. Whenconfig::active_server()returnsSome((url, key))and the resolved provider is non-local (!Provider::is_local()),run::run_agent_turnroutes the LLM call through${url}/v1/chat/completionswithAuthorization: Bearer ${key}instead of dispatching to a per-provider module. Reusesllm/openai'spub(crate)request/response shapes verbatim — the server speaks the OpenAI shape so duplicating structs would let them drift. Streaming reusesllm::stream::drive_openai_compatible_stream. Maps the server's{"error":{"code":..,"message":..}}envelope intoAictlError::{Auth,Injection,Redaction,Provider}. Performs a once-per-processGET /healthzprobe before the first proxied request — non-2xx warns and proceeds, network failure warns and proceeds (the next chat call surfaces the real error).Provider::is_local()returnstrueforOllama/Gguf/Mlxonly.
- Config:
~/.aictl/configonly — no.env, no system env vars for program parameters. CLI args override.config_set/config_unsetwrite through to disk and cache.config_overlaymutates the in-memory cache only (used by ephemeral CLI flag overrides like--client-url);keys::override_secretis the matching mechanism for secrets — its value beats both keyring and plain config inkeys::get_secretlookups.config::Role(Cli/Server) +config::set_rolemark which binary loaded the engine;config::config_get_scoped(server_key, cli_key)readsserver_keyfirst when role=Server (falling back tocli_keyif unset) and ignoresserver_keyentirely when role=Cli — used bysecurity::load_policy,redaction::load_policy, andaudit::enabled. The CLI never sets a role (defaults toCli);aictl-server'smaincallsset_role(Role::Server)immediately afterload_config. - Server-scoped security/redaction flags: every CLI flag that has a meaning in a pure HTTP proxy has a paired
AICTL_SERVER_*form:AICTL_SERVER_SECURITY,AICTL_SERVER_SECURITY_INJECTION_GUARD,AICTL_SERVER_SECURITY_AUDIT_LOG,AICTL_SERVER_SECURITY_REDACTION,AICTL_SERVER_SECURITY_REDACTION_LOCAL,AICTL_SERVER_REDACTION_DETECTORS,AICTL_SERVER_REDACTION_EXTRA_PATTERNS,AICTL_SERVER_REDACTION_ALLOW,AICTL_SERVER_REDACTION_NER,AICTL_SERVER_REDACTION_NER_MODEL. Tool-dispatch knobs (CWD jail, shell allow/block lists, blocked env vars, disabled tools, max-write byte cap, shell timeout) are intentionally not mirrored — the server does not run tools. - aictl-server routing: opt-in only. The proxy is reached only when
Provider::AictlServeris the active provider (via--provider aictl-server,AICTL_PROVIDER=aictl-server, or theaictl-server:section in/model). Picking any other provider goes straight to that provider's API regardless of whetherAICTL_CLIENT_HOSTis set — the user's chosen provider wins. SettingAICTL_CLIENT_HOST/AICTL_CLIENT_MASTER_KEYis not required to use the CLI; both keys are inert until the user explicitly picks theaictl-serverprovider. When that provider is selected, dispatch goes through${AICTL_CLIENT_HOST}/v1/chat/completionswithAuthorization: Bearer ${AICTL_CLIENT_MASTER_KEY}; missing config surfaces a clearAictlError::Otherfrom the agent loop.AICTL_CLIENT_MASTER_KEYfollows the standard/keyslifecycle (plain config → keyring on lock); it is not the same secret asAICTL_SERVER_MASTER_KEY(which lives on the server side), but both keys participate in the same/keyslock/unlock/clear flow so a co-located host can move them together. The server resolves its key throughkeys::get_secretso a locked entry is found in the keyring transparently.--client-url <URL>and--client-master-key <KEY>override for one launch without persisting. The/modelmenu fetches${url}/v1/modelswhenactive_server()resolves and shows the catalogue under anaictl-server:section — failures (server down, wrong key) just produce an empty list rather than aborting the menu./pingprobes/healthzthen exercises the master key against/v1/models./balancealways probes the upstream provider endpoints directly with the operator's local keys (the server's/v1/statsreports dispatch counts, not balances). The dispatch branch lives inrun::run_agent_turnonly (CI gate:grep -rE 'server_proxy::call' crates/aictl-core/src/ | grep -v 'run.rs\|server_proxy.rs'must be empty). - Prompt file:
AICTL.mdin CWD is appended to system prompt (override viaAICTL_PROMPT_FILE). Falls back toCLAUDE.mdthenAGENTS.mdunlessAICTL_PROMPT_FALLBACK=false. - Security gate: every tool call passes through
security::validate_tool()before exec and output sanitization on return.--unrestrictedbypasses validation; audit + redaction keep running. Themcp__*arm enforces a body-size cap (max_file_write_bytes) andAICTL_MCP_DENY_SERVERS; the CWD jail does not apply to MCP tools because the server runs in its own process with its own privileges. Workspace carve-out for blocked paths:security::check_path_withskips the blocked-paths rejection when the activeworking_diris itself anchored inside that blocked tree and the target is inside the workspace — so the desktop default~/.aictl/workspace/(under the blocked~/.aictl/) is usable while siblings like~/.aictl/keysand~/.aictl/auditstay off-limits. - CWD jail root:
security::load_policyreadsstd::env::current_dir()forpaths.working_dir, which is the jail root and the spawn dir for every tool subprocess.apply_cwd_override(inaictl-cli/src/main.rs) resolves the working directory in this order:--cwd <PATH>flag, thenAICTL_WORKING_DIR_CLIconfig key (canonical CLI-specific anchor, parallel toAICTL_WORKING_DIR_DESKTOP), thenAICTL_WORKING_DIR(legacy unsuffixed fallback — kept working for existing configs;_CLIwins when both are set), then the process launch dir. The chosen path is canonicalized (handles~and relative inputs), verified to be a directory, andset_current_dird into before any subsystem reads the launch dir — so the same anchor flips the jail root,config::load_prompt_file, andconfig::local_config_roottogether. Bad paths exit loud rather than silently falling back. CLI-only —apply_cwd_overrideis not called fromaictl-server(the server has no tool dispatch and no jail). - Redaction: two seams.
run::redact_outboundruns right before provider dispatch — local providers (Ollama/GGUF/MLX) skipped unlessAICTL_SECURITY_REDACTION_LOCAL=true, andBlockmode aborts the turn there.redaction::redact_for_persistenceruns at write-time insession::save_messages(CLI + desktop) andrepl::add_redacted_history(CLI rustyline buffer +~/.aictl/historyon exit); both treatBlockasRedactso the offending message lands as[REDACTED:<KIND>]on disk rather than verbatim. The in-memoryVec<Message>is never mutated by either seam. - Streaming:
call_X(..., on_token)—Some→ streaming path,None→ buffered.StreamStateincrates/aictl-core/src/llm/stream.rsholds back anything that could prefix<tool name="so tool XML never hits the UI. Auto-disables under--quiet, compaction, agent-prompt generation, non-TTY stdout. The transport-level streaming flag inrun_agent_singleis not aware of--format, sotext/jsonmodes still receive deltas;PlainUI::stream_chunkdiscards them on those paths andshow_answerdoes the emission. Skips termimad markdown re-render. - Agent loop: up to 20 iterations. Every provider call wrapped in
tokio::time::timeout(AICTL_LLM_TIMEOUT, default 30s;0disables). - Key storage:
keys::get_secret(name)checks keyring first, falls back to plain config.keyringv3 needsapple-native+sync-secret-servicefeatures or it silently uses a mock store. - CLI flags: long-form only. Only short flags are
-v/-h. - Cargo features (default off):
gguf(llama-cpp-2),mlx(macOS+aarch64),redaction-ner(gline-rs). Model management CLI paths compile on every build; only the inference call is feature-gated and returns a rebuild hint when missing. - Coding-agent mode (experimental, default off): base-prompt override gated by
AICTL_CODING_AGENT(defaultfalse). When on (andRoleis notServer),run::build_system_promptreturnsSYSTEM_PROMPT_CODINGinstead ofSYSTEM_PROMPT— same XML tool spec and tool catalogue, prose adds the Explore → Plan → Code → Review → Test discipline. Phase 2 tool surface (universal — applies in non-coding mode too, but the coding prompt is where the model is steered to actually use it):edit_fileaccepts multiple<<< … === … >>>blocks per call applied top-to-bottom and atomic (any block failure aborts the write — no partial state on disk), each optionally scoped by@N/@N-Mto a 1-based inclusive line range; on a zero-hit exact match the tool retries with whitespace normalized per line (runs of spaces/tabs collapsed, trimmed) and applies the model'snewtext verbatim if exactly one fuzzy candidate exists, otherwise surfaces an "N candidates" error rather than guessing.search_filesandfind_filesshell out torgwhen it is on PATH (respecting.gitignore, supporting--regex/--type/--case/--context/--max/--no-ignoreon search and--typeon find) and fall back to the existing pure-Rust path otherwise — probe cached in aOnceLock<bool>per process, override withAICTL_TEST_FORCE_RG_FALLBACK=1.read_filetakes an optional second-line--lines [N|N-M]flag that returns the requested slice withNNNNN:line-number prefixes (bare--linesnumbers the whole file; end-of-range past EOF clamps with a trailing(end of file at line N)note; start past EOF returns(file ends at line N, no content)). All grammars stay additive: the single-blockedit_file, the barepattern\ndirsearch_files, and the unflaggedread_file pathkeep working unchanged. The security gate'ssearch_files/find_filesdir extraction was updated to skip--flaglines so the policy still sees the actual directory under the new grammar. For production coding work prefer dedicated tools (Claude Code, OpenAI Codex CLI, opencode); aictl's mode is for quick edits and exploration.coding_agent_enabled()inaictl-core::configshort-circuits tofalseunderRole::Serverso a server reading the shared config never adopts the coding prompt. The CLI exposes--coding-agent/--no-coding-agent(one-launch overlay), the/coding on|off|toggle|statusslash command (persisted to disk; legacy/coding-agentand/coding_agentstill route through),/skip [review|test]for phase shortcuts, a dim[phase]prefix in the REPL prompt, and acoding:line in--info. The desktop exposes the same master switch via thecoding_agent_status/coding_agent_set_enabledTauri commands, surfaced in Settings → General → Coding agent and as a chevrons-in-square composer-toolbar icon (slotted betweenmemoryandauto-accept); the composer is a single toolbar row (model picker + icon cluster ending in the ⌘↵ Send button, slotted right of the mic), sotauri.conf.jsonminWidthandApp.tsx::CHAT_MIN_WIDTHneed to fit that row (locked together at 860). Phase tracker,/skip, and the[phase]indicator are CLI-only in v1 — the desktop benefits from the prompt steering but doesn't render phase UI.WorkflowPhaseenum + auto-detect helpers live inaictl-core::coding. Auto-detection incoding::detect_linter/detect_test_cmdcovers Rust, Node, Python, Go, Gradle, Maven, CMake, and Make; project-level commands prefer wrappers (gradlew,mvnw) when present and preferclang-tidyfor C/C++ whencompile_commands.jsonexists, falling back tocppcheck. The per-filelint_fileregistry adds.java(google-java-format→checkstyle→javac -Xlint) and.kt/.kts(ktlint→ktfmt) alongside the existing groups. Phase 3 (thetesttool is callable in non-coding mode; retry loop and Review hook fire only whencoding_agent_enabled()): dedicatedtesttool (tools/test.rs+tools/test/parsers.rs,TOOL_COUNT36) shells the auto-detected runner (cargo / npm / pytest / go / gradle / maven / ctest / make), parses pass / fail / skipped counts plus per-failure detail, and stores a structuredTestSummaryon a private async slot. Body grammar: empty (auto-detect),<filter>(threaded throughcargo test <f>/pytest -k <f>/go test -run <f>/npm test -- <f>/./gradlew test --tests <f>), or--cmd <command>escape hatch. In coding-agent mode the agent loop drains the summary after everytestdispatch and, onfailed > 0, injects a<test_failure>user turn carrying the structured failures (capped at 25 / 400 char message) so the model can plan a fix; the host caps atAICTL_CODING_TEST_RETRIESre-loops and switches to<test_failure_terminal>once exhausted. A<repo_context>block (branch, last 5 commits, dirty files, top-level layout depth 2, detected build / lint / test commands) is appended toSYSTEM_PROMPT_CODING; cached per working dir incoding::collect_repo_context, busted bycoding::invalidate_repo_contextafter everywrite_file/edit_file/remove_file/create_directoryso the dirty-files list stays current.coding::detect_build_cmdjoinsdetect_linter/detect_test_cmd. When the model emits a no-tool-call response in coding-agent mode and the session has touched at least one file, the host runs a structured Review hook (coding::run_structured_review) before releasing the answer: the project build +lint_fileon each changed path. On Pass the answer ships with a[review: clean — …]banner prepended; on Fail the host pushes a<review_result>user turn carrying the build / lint output tails andcontinues the loop, capped atAICTL_CODING_REVIEW_RETRIES(default 2) before releasing with a[review: N attempt(s); failures may remain]banner. New config keys:AICTL_CODING_BUILD_CMD,AICTL_CODING_REVIEW_RETRIES,AICTL_CODING_REPO_CONTEXT/_TREE_DEPTH/_TREE_MAX,AICTL_CODING_TEST_FILTER_DEFAULT. CLI gains/coding refresh(busts the repo-context cache + clears the changed-paths tracker) and three new lines under--info(build:,lint:,test:) when coding-agent mode is on;/coding statusshows the resolved commands plus the test / review retry budgets and the per-session changed-files count. Desktop gains three Tauri commands (coding_agent_build_cmd/coding_agent_lint_cmd/coding_agent_test_cmd) and a read-only "Resolved commands" section under Settings → Coding agent. Phase 4 (universal — the multi-tool grammar applies in non-coding mode too):tools::parse_tool_callsreturnsVec<ToolCall>so one model response can carry multiple<tool>blocks. Read-only batches (every name returningtruefromtools::is_parallelizable—read_file/list_directory/search_files/find_files/git status|log|blame|diff/lint_file/check_port/system_info/fetch_url/extract_website/read_document/read_image/json_query/csv_query/calculate/fetch_datetime/fetch_geolocation/clipboard read/diff_files/checksum/list_processes) dispatch concurrently viatokio::task::JoinSetinrun::handle_tool_batch, chunked byAICTL_CODING_PARALLEL_TOOLS_MAX(default 4, clamped to 16;0disables and forces serial — the loop runs only the first call and pushes a single<tool_results>rejection envelope for the rest). Side-effect classification lives intools::SIDE_EFFECT_TOOLSplus body inspection forgit commit(vs read subcommands) andclipboard write(vsread); MCP and plugin tools are conservatively non-parallel. When a batch mixes side-effects with reads, the host short-circuits: only the first side-effect dispatches (serially, through the unchangedhandle_tool_callpath so its security gate / hooks / audit / Review-hook seams all run); the read-only siblings land in a single<tool_results>envelope as rejection notes so the model re-emits them next turn. Per-call<tool_result>blocks join in source order regardless of completion order; per-call hooks, per-call audit, and the redactionBlockseam all fire independently. The duplicate-call guard is single-call-only (each in-batch call still has its own check insidetools::execute_tool). Approval is bundled —*autoauto-approves the whole batch; otherwise the user confirms once on the first call and the decision applies to all.--infogains aparallel:line (showing the cap or "disabled"). Mid-stream streaming wires:StreamStatescansfullfor<phase>NAME</phase>and emitsStreamEvent::PhaseChange(WorkflowPhase)on the same mpsc channel that already carries deltas / suspend;AgentUI::on_phase_changeis a default-no-op the CLI'sInteractiveUIoverrides to store the latest phase in aMutex<Option<WorkflowPhase>>, drained by the REPL viatake_latest_phaseafter each turn — so the prompt prefix flips to a model-claimed phase even when the tag fired in an intermediate LLM call rather than the final answer. CI gate:grep -rE 'parse_tool_calls|dispatch_parallel|handle_tool_batch|PhaseChange' crates/aictl-server/src/must be empty (Phase 4 lives in the engine + CLI; the server has no agent loop).
- Rust edition 2024, default rustfmt and clippy settings.
- After finishing work, run
cargo lintand fix any warnings, then runcargo fmtto re-format code if needed. - Commit messages follow
.claude/skills/commit/SKILL.md— no AI attribution, imperative mood, short for small changes. - After implementing a feature or fixing a bug, check
ROADMAP.md— remove the item if resolved. - Claude Code skills live in
.claude/skills/—/commit,/update-docs,/evaluate-rust-quality,/evaluate-rust-security,/evaluate-rust-performance. Evaluation reports land in.claude/reports/.