feature/cargo-workspace
AGENTS.md
This file provides guidance to AI coding agents when working with code in this repository.
What this is
sigit ("siGit Code") is a single Rust binary: a local-first AI coding agent that runs LLM
inference on-device (via the onde crate / GGUF models) or against a hosted/OpenAI-compatible
endpoint. It exposes itself two ways from the same binary, chosen at startup by whether stdin
is a TTY:
- ACP mode (stdin not a TTY): speaks the Agent Client Protocol over stdio for editor integration (Zed, VS Code ACP Client). Cross-platform.
- Interactive terminal mode (stdin is a TTY): a full-screen ratatui chat UI. Unix-only — it relies on fd redirection to keep logs out of the TUI, so Windows gets ACP mode only.
Before the TTY/ACP split, main also dispatches the account subcommands sigit login,
sigit logout, sigit whoami (see crates/cli/src/main.rs main()).
Working in this repo
IMPORTANT — branch naming: Prefix every working branch with feature/ (new functionality)
or fix/ (bug fixes) — never a tool- or agent-name prefix like claude/. Name the branch after
the changes it contains, as a short, descriptive, kebab-case slug that is self-explanatory
from the name alone (e.g. feature/tool-permission-system, fix/glob-mtime-sort), never after
a task, ticket, or session id (not feature/task-q003hm).
IMPORTANT — pull request target: Always open pull requests against the development branch,
never main. main is release-only; development is where day-to-day work integrates, and the
release merge (see the sigit-code-release skill) is the one thing that ever puts commits on
main.
This rule needs help to hold, because the repository's default branch is main: both
gh pr create and the GitHub web form pre-select it, so a PR lands on the wrong base unless the
base is passed explicitly. Pass it every time, and check that it took:
gh pr create --base development --head <branch>
gh pr view <number> --json baseRefName
Retargeting a PR that was opened against main costs nothing before it merges:
gh pr edit <number> --base development. After it merges it costs real work — the change sits on
main while development, which the next release is cut from, does not have it, and someone has
to carry it back by hand. PR #65 went in that way.
IMPORTANT — branch off development: Start every working branch from the latest
origin/development. Exception: when new work depends on a feature branch that has not merged
yet (e.g. it builds on tools or APIs that branch introduces), it may be stacked on top of that
branch instead. When stacking: merge the base PR into development first, then rebase the
stacked branch onto development before opening its pull request, so each PR shows only its own
commits.
IMPORTANT — run CI before pushing: Run the full CI gate locally and confirm it is green before pushing a branch or opening a pull request — never push work that fails these:
cargo fmt -- --check # formatting
cargo clippy --tests -- -D warnings # lint (warnings are errors)
cargo test --locked # tests
Agent assets layout
.agents/ is the canonical home for agent assets; every other agent path in
the repo is a symlink into it, never a copy:
.agents/AGENTS.md— this file, the project instructions. The rootAGENTS.mdandCLAUDE.mdare symlinks to it, so agents.md-standard tools, Claude Code, and sigit itself (viacrates/cli/src/instructions.rs, which prefersAGENTS.mdoverCLAUDE.mdin the same directory and therefore reads it once) all load the same content. Edit this file; never replace the root symlinks with real files..agents/skills/— all project skills..claude/skillsis a symlink to it, which is how both Claude Code and sigit itself (viacrates/cli/src/skills.rs) discover them. Add new skills under.agents/skills/<name>/SKILL.md, never as real files under.claude/.
Workspace layout
The repo root is a Cargo workspace (members = ["crates/cli"], with crates/sigit-* added as
they land). The sigit binary crate lives in crates/cli: its src/ and tests/ are what the
architecture section below calls src/ and tests/, and it publishes as sigit from there.
Shared package fields and every dependency version are declared once in the root
Cargo.toml ([workspace.package], [workspace.dependencies]) and inherited with
workspace = true. Build outputs are unchanged: target/<triple>/release/sigit. Release
workflows build and publish with -p sigit.
Build / test / lint
cargo build # debug build
cargo build --release # release binary at target/release/sigit
cargo run -p sigit # launches interactive TUI (stdin is a TTY)
cargo test # CI runs: cargo test --locked --target <target>
cargo clippy --tests -- -D warnings # CI gate: clippy is -D warnings on all 4 targets
cargo fmt -- --check # CI gate (edition 2024)
CI (.github/workflows/ci.yml) runs fmt + clippy + test across four targets:
aarch64-apple-darwin, x86_64-apple-darwin, x86_64-unknown-linux-gnu,
x86_64-pc-windows-msvc. Clippy is -D warnings, so warnings fail the build.
Run a single test: cargo test <test_name>.
Critical platform constraint: #[cfg(unix)] dead code
The interactive client is #[cfg(unix)]-only. The InferenceBackend seam (backend.rs) and
provider resolution (provider.rs) are consumed by both the interactive client and the ACP
server, but several of their items are reached only through the Unix-only interactive paths, so
the dead-code lint is suppressed on non-Unix targets only.
Consequence: code can pass clippy on macOS/Linux but fail on the Windows target (or vice versa).
When touching backend.rs, provider.rs, or the interactive path, keep the cfg gates intact —
don't "fix" an unused-warning by deleting code that's live on Unix.
Architecture
The agent loop is backend-agnostic. The flow: a turn (messages + tool specs) goes to an
InferenceBackend, which returns assistant text and/or tool calls; the loop executes tools and
feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete backend.
crates/cli/src/main.rs— entry point, mode dispatch, the full ACPAgentimpl (session lifecycle: new/load/fork/prompt/cancel, config options, slash-command advertisement), and theSYSTEM_PROMPT. ACP session state owns its roots, conversation, and selected model even though the process has one working directory and one live backend slot; activating a thread parks and restores all three. A thread on an HTTP backend keeps a backend of its own (InferenceBackend::fresh,SessionState::remote), so its turn can wait on the endpoint while another thread is installed. Unknown session ids are rejected instead of silently borrowing the active thread's cwd. Prompt cancellation takes no lock, which lets a client cancel a turn whatever it holds.$/cancel_requestis the other way to stop a request, naming it by JSON-RPC id. The SDK only records it; a handler has to readresponder.cancellation(). The prompt handler does and callscancel_turn, the same pathsession/canceltakes, so the prompt still answers with acancelledstop reason.authenticateis dropped instead and answers-32800. The other handlers ignore it, which the spec allows.session/loadreplays the saved history assession/update(history_replay_updates): user text and its image and audio parts, reasoning as a thought chunk under the reply's id, and each tool call folded together with its result. Message chunks carry amessageId: each inference round is one message, so every chunk of it, reasoning included, sharesStreamedReply::message_idand a tool round starts a new id. A standalone notice (send_assistant_message) gets an id of its own, and replayed messages get fresh ones; ids are random, so none is stored in history. The Inference (local/cloud) config option is abooleanfor a client that advertisessession.configOptions.boolean(read intoCLIENT_BOOLEAN_CONFIG_OPTIONSininitialize) and a two-value select for any other;handle_set_session_config_optiontakes either value.session/loadandsession/resumesharerestore_session; the only difference is that resume must not replay the history assession/update.session/closeis the one place aSessionStateis dropped. It runs in two halves:begin_closesignals the session's turn from the dispatch loop (the turn holds the session's lock, so the signal cannot wait for it) and marks the id inclosing_sessions, which makeshandle_promptcancel a prompt that was still queued;handle_close_sessionthen runs under the session and workspace locks and removes the state, the permission grants and the background commands (tools::kill_session_tasks). It leavessession_storealone, so a closed thread still lists and reopens.session/deleteis the one that removes it from disk: it runs the same two halves (begin_close, thenhandle_close_sessionfromhandle_delete_session) and thensession_store::delete, and succeeds for an id that is already gone. TheSYSTEM_PROMPTbakes in smbCloud-specific context the agent should use when the repo is clearly smbCloud, and stay general otherwise.crates/cli/src/backend.rs— theInferenceBackendtrait and neutral types (ToolSpec,ToolCall,ToolResult,TurnResult). Two impls:LocalBackend(on-device viaonde::ChatEngine) andOpenAiBackend(any OpenAI-compatible HTTP endpoint). ABackendErroris user-facing: the ACP prompt handler passes it to the client verbatim and the editor puts it in an error banner, sodescribe_api_errorunwraps the OpenAI{"error":{"message":…}}envelope and shows that message on its own, falling back to the status only when there is nothing to unwrap. An endpoint can also fail after the response is open, reporting it as adata:frame holding the same envelope; that frame has nochoices, soconsume_streamhas to check for it explicitly or it parses as an empty chunk and the turn ends looking like an empty answer.TurnResult::context_tokenscarries the endpoint's token count (prompt plus completion) up to the loop, which sends it as ausage_updateafter each model response, with the model's pickercontext_window_tokensassize; with no count (always on-device) the history is estimated instead. A streamed request asks for the count withstream_options.include_usage, and an endpoint that rejects the option is asked again without it and never asked again (stream_usage_refused).TurnResult::finishcarries the endpoint'sfinish_reasonup to the loop, andstop_reason_forinmain.rsturns it into the ACP stop reason: the tool-round cap ismax_turn_requests,lengthismax_tokens,content_filterisrefusal. ACP defines a refusal as a turn the next prompt will not include, sohandle_promptrestores the history it snapshotted before the turn instead of only relabelling the response. The round that reaches the cap (24, orSIGIT_MAX_TOOL_ROUNDS) is followed by one forced text reply with no tools. The model cannot see that the tools are gone, soround_cap_noteis appended to that round's last tool result, and the turn always closes withround_cap_stop_message. Without both, the thread stops on the model announcing a step that never runs (issue #120). Some models write tool calls into content as text;crates/cli/src/inline_tool_calls.rsrecovers the well-formed ones. A block that doesn't parse (or never closes) is dropped from both the reply and history, andOpenAiBackend::completeretries once with a note telling the model the call didn't run. Leaving the raw block in history makes the model invent<function_results>later. What that scanner lets through then goes throughcrates/cli/src/harness_markup.rs, which removes markup the model makes up in the harness's shape (issue #122): a<system_warning>-style block (anysystem_*/system-*tag) is dropped whole, from the reply and the history, and other underscore-named tags like<Option_Picker>lose the tags but keep their text. Code is never touched, and an opening tag only counts at the start of a line, since prose writes placeholders like<repo_url>the same way. It has to run after the inline-call scanner, because<tool_call>matches its underscore rule.crates/cli/src/repetition.rsguards the same stream against a reply that degenerates into a loop over a tiny vocabulary (issue #123): a window of 200 words with at most 40 distinct ones, fenced code excepted, makesconsume_streamstop reading, truncate the text to where the loop began (so history never holds the junk), tell the user, and finish withFinishReason::Repetition(EndTurnin ACP).completethen retries once withREPETITION_RETRY, like the malformed tool call retry. Only the streaming path is guarded;consume_jsonandLocalBackendare not. Image attachments: ACP fixespromptCapabilities.imagefor the whole connection, while the model can change on any turn, so the capability is always advertised and the decision is made per prompt.InferenceBackend::accepts_imagesanswers for the active model (on-device: no; remote:provider::model_accepts_images, which reads the hard-codedIMAGE_TIERSforonde-*ids and says yes for a user's own endpoint).images_for_turninmain.rseither passes the images tosend_message_with_imagesor drops them with a note to the user and a bracketed note to the model. A message with images is stored in history as OpenAI content parts and stays that way;OpenAiBackendstrips the images per request (without_images) when its model cannot read them, which is what makes a mid-thread switch to a text-only tier safe. Read history text throughbackend::message_text, nevercontent.as_str().IMAGE_TIERSmirrors onde-cloud'sIMAGE_CANDIDATES; update both together. Audio attachments take the same path (promptCapabilities.audio,accepts_audio,audio_for_turn,without_audio), sent as OpenAIinput_audioparts. No cloud tier takes audio, because onde-cloud dropsinput_audioparts when it parses a request, soprovider::model_accepts_audiosays no for everyonde-*id and yes for a user's own endpoint. If onde-cloud ever routes audio, give it a tier table likeIMAGE_TIERS.crates/cli/src/provider.rs— decides which backend serves inference. Resolution order, first match wins: (1) override viaOPENAI_BASE_URL+OPENAI_API_KEYor active profile in~/.config/sigit/providers.toml; (2) siGit Code Cloud when logged in; (3) on-device.crates/cli/src/tools.rs— agent tool schemas + execution:read_file,create_directory,list_directory,search_files,glob,read_website,create_file,edit_file,multi_edit,delete_file,run_command,write_todos,remember. Add a tool in both the spec list (all_tools) and the executematch(execute_tool).run_commandalso enforces commit attribution: when a command creates a new commit that lacks theCo-Authored-By: siGit Codetrailer (commit_co_author_trailer(), which readssiGit Code v<version>-<acp|tui|headless>from the surfacemainsets viaset_surface), it amends the trailer in — unless the commit already exists on a remote, which is never rewritten. Every child process it spawns (spawn_shell, thegithelpers, andhooks.rs) setsstdinto null and never inherits it: in ACP mode sigit's stdin is the JSON-RPC pipe from the editor, so a command that reads stdin both blocks forever and eats the client's next request, wedging the session past any timeout. On Unix the shell also leads its own process group so the timeout kills the whole tree, and stdout/stderr are drained on threads while the command runs — waiting first and reading after deadlocks as soon as the output outgrows the pipe buffer. Also owns thetasktool: a nested agent loop in a fresh conversation, offered only whensubagent_available()(a subagent factory is registered — seeregister_subagent_factory_forinmain.rs; on-device registers aNone-returning factory since onde has a single shared history). A subagent's toolset is a hard-gated read-only allow-list (SUBAGENT_TOOL_NAMES) that is never expanded by a configurable subagent type (seecrates/cli/src/subagents.rs) — only ever narrowed — so a.sigit/agents/*.mdfile can't grant itselfedit_file/run_commandand bypass the permission system. Also ownsweb_search: a thin native wrapper around the official MCP server's Brave-Search-backedweb_searchtool (mcp__sigit__web_search, implemented server-side insigit-si'sWebSearchService+Mcp::Tools::WebSearch), offered only when that MCP tool was actually discovered (i.e. the user is signed in to siGit Code Cloud — seeweb_search_available). Wrapping it natively rather than leaving it as a rawmcp__*tool matters for two reasons:permissions::classifytreats everymcp__*tool as mutating (an "ask" prompt on every call), whileweb_searchis read-only likeread_website; and the raw delegate name is filtered out of the assembled tool list (is_web_search_delegate) so the model sees one clean option, not two names for the same tool. Execution still forwards verbatim tomcp::call_tool— no second HTTP/JSON-RPC implementation.crates/cli/src/skills.rs— Agent Skills support. Discovers skill folders (each with aSKILL.md: YAML frontmattername+description, then Markdown instructions) from.sigit/skills/and.claude/skills/in every project root (seecrates/cli/src/workspace.rs),$SIGIT_CONFIG_DIR/skills/, and~/.claude/skills/. Progressive disclosure: the discovery list (name + description) is baked into the dynamically-builtskilltool's description, and activating a skill (the model callsskillwith a name) loads the fullSKILL.mdbody. Theskilltool is appended in the*_as_specs/build_tool_specslayer (not inall_tools()) so its description can be dynamic, and only when at least one skill exists.crates/cli/src/commands.rs— user-defined slash commands. Discovers Markdown files (each an optional YAML frontmatter block —description,argument-hint— followed by a prompt-template body) from.sigit/commands/and.claude/commands/in every project root,$SIGIT_CONFIG_DIR/commands/, and~/.claude/commands/. A subdirectory namespaces the command with:(.sigit/commands/git/commit.md→/git:commit). Unlike skills there's no tool-call indirection: invoking one works exactly like the built-in/init—commands::rendersubstitutes$ARGUMENTS/$1..$9in the body against whatever followed the command on the input line, and the result is fed to the model as a normal turn through the ordinary tools and permission checks. Resolution happens where each surface (main.rs/chat.rs) already special-cases/init: an unrecognized slash command is tried againstcommands::resolve_commandbefore falling back to "unknown command". A custom command sharing a name with a built-in is unreachable (built-ins match first inparse_slash) and is skipped when advertised to ACP clients, with a warning logged.crates/cli/src/subagents.rs— configurable subagent types for thetasktool. Discovers Markdown files (YAML frontmattername+description, optional comma-separatedtools:allow-list, then a Markdown body that becomes the subagent's system prompt) from.sigit/agents/and.claude/agents/in every project root,$SIGIT_CONFIG_DIR/agents/, and~/.claude/agents/. Passing a type'snameastask'ssubagent_typeargument swaps in that system prompt and, iftools:is set, narrows the offered toolset to its intersection withSUBAGENT_TOOL_NAMES— the security-relevant narrowing logic lives intools.rsnext to that constant, not here; this module only discovers and parses files.SubagentFactory(intools.rs) takes the resolved system prompt per call rather than baking one in at registration, so a single registered factory serves both the default research subagent and every configured type.crates/cli/src/frontmatter.rs— shared "YAML frontmatter + Markdown body" parsing used by bothcrates/cli/src/skills.rs(SKILL.md) andcrates/cli/src/commands.rs(.sigit/commands/*.md).crates/cli/src/mcp.rs— Model Context Protocol client. Two transports: Streamable HTTP (one JSON-RPC POST endpoint,urlinmcp.toml; replies areapplication/jsonor SSE) and stdio (command+ optionalargs/[server.env]inmcp.toml; sigit spawns the server and speaks newline-delimited JSON-RPC over its stdin/stdout, stderr inherited into sigit's log).urlandcommandare mutually exclusive — both or neither is a config error, logged and skipped. Both transports run the sameinitialize/tools/listhandshake and forwardtools/call. Discovery is best-effort at startup (mcp::init, called from both branches ofmain()) and cached in a process-global so the synchronous spec builders (mcp::tool_specs) and the async dispatch (mcp::call_tool) can both read it;/reloaddoes not re-run it, so config changes need a restart. stdio children live for the process; a dead child fails calls with an in-band error string (no auto-restart). Tools are namespacedmcp__<server>__<tool>, appended in the*_as_specs/build_tool_specslayer and routed intools::execute_toolviamcp::is_mcp_tool. Two servers are baked in: the official server (<cloud>/mcp, defaulthttps://sigit.si/api/v1/mcp, always HTTP, authed with the cloud session token) and the smbCloud CLI server (smb --mcp, stdio, added only when thesmbbinary is onPATH; opt out withsmbcloud = falseinmcp.tomlorSIGIT_MCP_SMBCLOUD=off). A user-defined entry namedsigitorsmbcloudoverrides the corresponding baked-in one. Extra servers live inmcp.toml(global$SIGIT_CONFIG_DIR/mcp.tomland project-local.sigit/mcp.toml). The stdio path is covered bycrates/cli/tests/mcp_stdio.rs, driven by the test-onlycrates/cli/src/bin/mcp_stdio_stub.rshelper binary (excluded from the published crate viaexcludeinCargo.toml). The baked-in official server is also listed in the public MCP Registry assi.sigit/sigit— a remote Streamable-HTTP listing owned by thegetsigit/sirepo (server.jsonat its root, published by itsrelease-mcp-registry.yml), not this one. Because it's a remote server, the registry's URL-match rule forces a domain namespace (si.sigit↔sigit.si) verified by a DNS TXT record, not the GitHub-OIDC schemesmbcloud-cliuses for its package listing. Separate from all of the above are the servers an ACP client passes inmcpServersonsession/new,session/loadandsession/fork. ACP requires agents to connect to the stdio ones, and they belong to the session that named them, so they cannot live in the startup global:connect_session_serversreturns aSessionServersthatmain.rskeeps onSessionStateand installs withset_session_serverswhenever a session becomes live, the same way it swaps the roots.tool_specs,call_tooland/mcpread the startup servers and then the live session's. A client-supplied server whose name is already taken by a startup server is not connected, since both would claim the samemcp__<server>__prefix. Streamable HTTP entries are connected too, with the URL and headers the client sent, becausehandle_initializeadvertisesmcpCapabilities.http. SSE is not advertised (the MCP spec deprecated it), so a conforming client never sends one and a stray entry is skipped. An MCP server'selicitation/createis not forwarded to the ACP client as a form elicitation:ed-mcp's client handler declares no capabilities and has no hook for it, so servers are never offered elicitation, andrmcpanswerspingand refuses other server requests itself. Forwarding needs that hook ined-mcpfirst (tracked upstream atondeinference/ed#33).crates/cli/src/permissions.rs— tool permission policy. Every tool call passes throughdecision_forbefore executing: read-only tools always run; mutating tools (and allmcp__*/unknown tools) are governed by, in order: per-session plan mode (/plan— deny all mutating tools with a present-a-plan message), session "always deny" and "always allow" choices (deny checked first, both scoped bysession_grant_ruleand dropped with the session), per-tool overrides and the default mode from[permissions]insettings.toml(allow/ask/deny, defaultask;SIGIT_PERMISSIONSenv overrides the default). Onask, the ACP path sendssession/request_permission(allow once / allow for session / deny / deny for session) and the TUI pauses the inference task on a y/a/n/d prompt. On the ACP path the decision is taken before the call is announced, because it sets the announced status: a call that will ask startspending, the permission request carries that call's own id, and anin_progressupdate follows approval. That update also puts the card's title back, since the permission request overwrites it with the full arguments. The Manual/Auto/Plan choice reaches an ACP client twice: as thesigit-permission-modeconfig option, and as sessionmodeswithsession/set_mode, which the spec is retiring but some clients still render instead. Both are built fromPERMISSION_MODESinmain.rsand read the same state, so whatever changes the mode has to refresh both (ConfigOptionUpdateandsend_current_mode). Note: ACP turn-affecting handlers run incx.spawned tasks so the dispatch loop can route the client's permission answer mid-turn — don't move them back inline, and don't await client requests from inline handlers (deadlock). Two locks order them. A per-session lock (SiGitAgent::session_lock) is held for a whole request, so requests on one thread keep their order.SiGitAgent::workspace_lockguards what a session installs process-wide (cwd, workspace roots, session MCP servers, the live backend slot, the engine's conversation); lifecycle and config handlers hold it throughout, andhandle_promptholds it through aWorkspaceHoldthat it releases while waiting on an HTTP endpoint or a permission answer and retakes (reinstalling its session viaresume_workspace) before any tool runs. An on-device turn never releases it. Take the session lock first, never the other way round. A turn registers itsPromptCancellationat the top ofhandle_prompt, before it waits on either lock, and aPromptRegistrationguard clears it on every return, so asession/cancelthat arrives while the turn is still queued, switching threads, loading a model or running a slash command ends it ascancelled. The checks sit after each of those waits. A cancel (andbegin_close) also raises the session'stools::AbortSignal, which the localrun_commandpoll loop andclient_terminal::Route::runwatch, so a running foreground command is killed instead of finishing first. The signal is reset at the top of each turn, not when a tool starts, so a cancel that lands during a permission prompt still stops the command that follows. Permission-mode changes (session/set_mode, andsession/set_config_optionforsigit-permission-mode) are the exception to the lock order: the dispatch loop runs them with neither lock, because they touch only state keyed by session (permissions) and have to land mid-turn. They check the id againstsessions(-32002when unknown) and build the options from that session's own model (config_options_for_session), neveractivate_session. Unknown session ids are-32002(resource not found) everywhere, andauthenticateaccepts onlyAUTH_METHOD_ID. Tool execution, subagents included, still runs one session at a time.crates/cli/src/instructions.rs— project instruction files, the always-on counterpart to skills. ReadsAGENTS.md(the cross-tool agents.md standard) andCLAUDE.md, walking from the session cwd up to the repo root (nearest ancestor with.git, never above it), plus a global file under$SIGIT_CONFIG_DIR. Files are ordered outermost-first so the deepest (most specific) wins. In a multi-root project that walk is repeated for every root (load_workspace_instructions). The combined block is injected viasession_context_messageinmain.rs— pushed as a system message at every ACP session entry point (new/load/fork + model switch) and appended to the system prompt on the cloud and TUI-startup paths.crates/cli/src/workspace.rs— the directories the session treats as project roots. An editor can open several at once (Zed calls it a multi-root project) and ACP carries the extras asadditional_directorieson every session request; the headless CLI takes them as repeatable--add-dirflags. A client only sends those extras to an agent that advertisessessionCapabilities.additionalDirectoriesin itsinitializereply, so that capability inhandle_initializeis what makes the rest of this reachable — without it Zed keeps the first root, drops the others, and shows "This agent doesn't currently support multi-root workspaces". The process still has one working directory, so the extra roots live in a process-global here andproject_dirs()returns cwd-first, extras after. One process also serves every thread the editor has open, so that global (with the cwd, the backend conversation, and the background-task owner intools.rs) always belongs to the live session:main.rskeeps aSessionStateper session id andactivate_sessionparks the live one and installs the requested one before any prompt or config change runs. Don't read another session's roots from the global. Project-local discovery reads it: skills, slash commands, subagent types, and instruction files all scan every root. MCP is deliberately not on that list —mcp::initruns once at startup, before any session exists, so a second root's.sigit/mcp.tomlhas nobody to tell. (The servers a client names inmcpServersare a different thing and are per session; seecrates/cli/src/mcp.rs.)crates/cli/src/client_fs.rs— file reads and writes through the ACP client. A client that advertisesfs.readTextFile/fs.writeTextFileininitializeservesfs/read_text_fileandfs/write_text_filefrom its buffers, soread_filesees unsaved changes andedit_filelands in the open buffer instead of underneath it.tools.rshas no access to the connection, so this is a seam like the onemcp::call_toolgives MCP:handle_initializeregisters aClientFileSystem(AcpClientFsinmain.rs, holding the connection), andexecute_tool_implasksroute_forbefore it dispatchesread_file,create_file,edit_fileormulti_edit. Each of those tools is split into a parse step and a pure render/apply step (ReadFileCall,CreateFileCall,EditCall) that the disk path and the client path share, so the model gets the same result text either way. The writing tools also hand back the file before and after (tools::FileChange, viaexecute_tool_with_change), whichhandle_promptsends as ACPdiffcontent ahead of the result text. A permission request for one of them carries the same diff, worked out bypreview_file_changewithout writing anything and before the workspace is released, since a relative path resolves against the session's cwd. The two capabilities are independent: a client that only reads still gets its edits written to disk. The disk is the fallback throughout: nothing is registered in the TUI or headless modes, a path outside the session's roots is not routed, and a request the client fails or leaves unanswered for 30 seconds is retried on disk with a warning in the log. The client is only ever asked from inside a spawned prompt turn, never fromhandle_initializeitself (deadlock, same as permission requests). Existence checks andcreate_file's parent directories still use the disk.SIGIT_CLIENT_FS=offturns the routing off. Covered bycrates/cli/tests/acp_client_fs.rs.crates/cli/src/client_terminal.rs—run_commandin the ACP client's terminal. A client that advertisesterminalininitializeruns a foreground command itself (terminal/create, with the platform shell ascommandand the whole command line as one argument), andexec_run_command_via_clientembeds the terminal in the tool call asterminalcontent, so the user watches the output live and can stop the command from the editor. The seam isclient_fs's:handle_initializeregistersAcpClientTerminal, andexecute_tool_implasksclient_terminal_routebefore the blocking path. Embedding needs the tool call's id, which is whyexecute_tool_with_changetakes one. Whatrun_commandpromises stays put: the 120 s timeout ends interminal/kill, the output is capped, andCommitWatchstill amends the co-author trailer, checking HEAD on disk before and after. Background commands stay local (command_outputandkill_commandread their pipes), and so does a command whose directory is outside the session's roots, since nothing says the editor's machine can see it. Aterminal/createthe client fails means nothing ran, so the command runs locally; a failure after that is reported as the tool's error instead, because running it again could repeat its effects. The finished card keeps the terminal and drops the result text, which still goes out asraw_output.SIGIT_CLIENT_TERMINAL=offturns the routing off. Covered bycrates/cli/tests/acp_client_terminal.rs.crates/cli/src/chat.rs— the Unix-only ratatui TUI. Loading-spinner phase then chat; usestokio::select!to multiplex terminal events with streaming tokens.crates/cli/src/headless.rs— non-interactivesigit runexecution for scripts, CI, and Factory clients. The legacy-pform reaches the same parser. Every new run gets a UUID session id;--resume <id>restores that session throughsession_store, and--output jsonlemits a structured session/delta/tool/result stream while logs stay on stderr. Headless permission prompts collapse to denial unless the tool was pre-approved with--allow-tool.crates/cli/src/session_store.rs— durable conversations: one JSON-lines history file per session at$SIGIT_CONFIG_DIR/sessions/<id>.jsonl, written atomically, restorable into either backend. Each save also writes a<id>.meta.jsonsidecar naming the session'scwdand the extra roots of a multi-root project. That sidecar is what makes a thread listable: ACP'ssession/list(advertised assessionCapabilities.list, handled byhandle_list_sessionsinmain.rs— the editor's "Import Threads" picker) must report an absolutecwdper session and may filter on it, so a session without one is skipped there while still reopening by id throughsession/load. A reply holdsSESSION_LIST_PAGE_SIZEthreads;nextCursornames the last one on the page (SessionListCursor, modified time plus id) so a save or delete between two requests cannot shift the next page, and an unparseable cursor is-32602. Sidecars are written at save time, not at session start, so a thread nobody spoke in leaves nothing behind. Each ACP save (persist_sessioninmain.rs) is also sent to the client as asession_info_updatecarrying a freshupdatedAt, plus the title when it differs from the last one sent (announced_titles), so a new thread gets its title in the sidebar without waiting for the nextsession/list.crates/cli/src/setup.rs— model cache location, local model discovery, selected-model persistence. Must run (setup_shared_model_cache) before anything touchesChatEngine/hf-hub, since those read env vars once at init.crates/cli/src/account.rs— siGit Code Cloud auth (/login,/logout,/whoami); authenticates against the account API and stores a session token. Performs no console I/O. ACPlogout(the editor's sign out button, shown only becausehandle_initializeadvertisesagentCapabilities.auth.logout) and/logoutboth go throughSiGitAgent::sign_outinmain.rs. The request names no session, soleave_cloudmoves the parked threads off their cloud tier along with the live one and each of them gets a fresh model picker.crates/cli/src/browser_auth.rs— browser sign-in, the path the editor's "Sign in to siGit Code" button takes. sigit is a public OAuth client (client idsigit-code-cli, no secret, PKCE S256) against the authorization server at$SIGIT_API_URL/oauth. Two ways the code gets back: a loopback listener on127.0.0.1:0(the registration listshttp://127.0.0.1/callbackwith no port, and RFC 8252 §7.3 matching ignores the port, so one registration covers every run), or the out-of-band URN, where the server renders the code and the user pastes it. The resulting token goes intocredentialslike any other, and is served by/api/v1/userrather than the deprecated/api/v1/me— OAuth tokens are rejected there. The scopes requested areuser:read code:agent;code:agentis what reacheschat/completionsand the official MCP server. Surfaces: ACPauthenticate(loopback only, since a code cannot be pasted back; for a client that advertiseselicitation.url,sign_in_via_clienthands it the authorize URL as a URL-mode elicitation instead of launching a browser, ends the sign-in if the user declines, and sendselicitation/completeonce the code is redeemed;authenticateruns in a spawned task so that answer can be routed), a bare/loginin either chat surface, andsigit login(which adds the paste fallback, plus--pasteto force it and--passwordfor the old email/password prompt).crates/cli/src/credentials.rs— local session-token store (TOML,0600on Unix).crates/cli/src/models.rs— model-picker types shared across platforms.
Slash commands (/help, /models, /skills, /agents, /commands, /mcp, /login, /logout,
/whoami, /reload, /plan, /permissions, /init, /clear, /status) are advertised via
advertise_commands in main.rs and handled in both the TUI and ACP sessions. /init is
special: instead of replying directly it substitutes instructions::INIT_PROMPT for the user
text and runs a normal agent turn that explores the repo and writes (or improves) AGENTS.md
through the ordinary tools and permission checks. User-defined commands (see crates/cli/src/commands.rs)
get the same treatment via the SlashCommand::Unknown fallback path, so anyone can add their own
/name commands without touching the built-in command list.
Model cache (macOS)
On macOS the HF model cache lives in an App Group container shared with the siGit desktop app:
~/Library/Group Containers/group.com.ondeinference.apps/models/. Other platforms fall back to
~/.cache/huggingface/. The CLI reuses a model the desktop app already downloaded. First run
downloads a GGUF model (~1–2 GB) from Hugging Face.
Logging
In TTY (interactive) mode, all output — log, tracing, stray println! — is redirected to
$TMPDIR/sigit.log so the ratatui surface stays clean; the TUI holds a separate fd to the real
terminal. In ACP mode, stdout is reserved for protocol JSON and logs go to stderr. Control
verbosity with RUST_LOG.
Relevant env vars
OPENAI_BASE_URL / OPENAI_API_KEY (provider override), SIGIT_API_URL (account API base,
default https://sigit.si), SIGIT_CLOUD_URL, SIGIT_CONFIG_DIR (default ~/.config/sigit),
SIGIT_MODEL, SIGIT_MAX_TOOL_ROUNDS (1 to 500, default 24; the tool-round cap of a
headless run or an ACP prompt turn), SIGIT_MCP (off disables MCP), SIGIT_CLIENT_FS (off keeps the file tools on disk even when the
ACP client offers fs/read_text_file / fs/write_text_file), SIGIT_CLIENT_TERMINAL (off runs
run_command locally even when the ACP client offers a terminal), SIGIT_MCP_SMBCLOUD (off drops the baked-in
smbCloud CLI server), SIGIT_MCP_OFFICIAL (off drops the baked-in
server), SIGIT_PERMISSIONS (allow/ask/deny — overrides the default permission mode for
mutating tools; the escape hatch for clients without permission-request support),
HF_HOME / HF_HUB_CACHE, RUST_LOG.
Releasing
Version lives in [workspace.package] in the root Cargo.toml. Update CHANGELOG.md for releases.
Publishing splits into two groups. Language registries each get their own tag-triggered
workflow: release-crates, release-npm, release-nuget, release-pypi. The npm/, nuget/,
and pypi/ dirs hold their wrapper-package templates.
npm and NuGet authenticate with OIDC (Trusted Publishing), so neither needs a stored token. On
npm that is configured per package, not per org: all seven @getsigit/* names carry a trusted
publisher pointing at release-npm.yml, and a package added later needs the same setup or its
publish step will fail. OIDC also requires npm >= 11.5.1 and Node >= 22.14.0, which is why the
workflow upgrades npm and why .nvmrc cannot drop below 22.
OS package managers all publish somewhere outside this repo and all need checksums from the
GitHub release, so release-github builds the binaries, attaches the assets, and then dispatches
release-homebrew, release-scoop, release-winget, and release-aur. A dispatch failure in one
is logged as a warning rather than failing the others, so an unconfigured channel does not block a
release. Their inputs live in packaging/.
The Windows binaries link the MSVC C runtime statically (+crt-static in .cargo/config.toml).
winget, Scoop, npm, PyPI and NuGet all ship the bare exe with no installer, so a dynamic build
that imports VCRUNTIME140.dll fails to load on a machine without the VC++ Redistributable
(0xC0000135, which is what failed winget's first validation).
packaging/windows/check-static-crt.ps1 runs in CI and in release-github and fails the build if
a Windows exe imports the runtime again. Don't set a RUSTFLAGS env var in those workflows: it
replaces the config rustflags instead of adding to them.
Every release asset now carries a .sha256 sidecar, not just the macOS Homebrew tarball. Scoop,
winget, and the AUR PKGBUILD each need one, and they consume the raw binaries rather than the
tarball. release-github also builds a .deb and .rpm per Linux target with nfpm
(packaging/nfpm.yaml), packaging the already-built binary rather than re-invoking cargo.
Re-pushing a tag fires every release workflow a second time, so each one is grouped by tag in a
concurrency block with cancel-in-progress: false — the duplicate queues behind the original
rather than interrupting a publish that is halfway through uploading. What keeps the queued run
from going red is the "is this version already published?" check each publishing workflow does
before it uploads. Those checks are load-bearing, not belt-and-braces: v1.5.6 published twice
because the crates.io one asked the API with curl's default User-Agent, which crates.io answers
with a 403, which read as "not published yet".
Three of these need credentials or a one-time manual step before they work:
- Scoop needs a
getsigit/scoop-bucketrepo and aSCOOP_BUCKET_TOKENsecret, mirroring the Homebrew tap setup. - winget needs a
WINGET_TOKEN(PAT withpublic_reposcope) sowingetcreatecan forkmicrosoft/winget-pkgsand open the manifest PR. It is passed asWINGET_CREATE_GITHUB_TOKENrather than--token, which wingetcreate warns can leak the token into logs.wingetcreate updateonly works on a package that already exists inmicrosoft/winget-pkgs, so the workflow checks themanifests/g/getSigit/siGitCodepath first and falls back to renderingpackaging/winget/*.yaml.inand runningwingetcreate submitfor a first submission. That fallback runs once, then every later release takes theupdatepath. Two non-obvious things in the update path: the--urlsarguments carry a trailing|x64/|arm64suffix that tells wingetcreate which installer entry each URL replaces — without it, wingetcreate guesses from the file name, and "sigit-win-amd64.exe" is not a spelling it recognises; and the first-submission render uppercases the SHA256 (tr '[:lower:]' '[:upper:]') to match the community validation pipeline, which writes 64-hex checksums uppercase. The update path also passes--release-date,--release-notes-url,--submit, and--no-openexplicitly. - The AUR needs
AUR_USERNAME,AUR_EMAIL, andAUR_SSH_PRIVATE_KEY. It publishessigit-bin(a prebuilt binary) so Arch users are not compiling the on-device inference stack to install a CLI.
[profile.release] sets strip = "symbols" because binary size is a distribution constraint, not
just a nicety. See the NuGet note below.
The NuGet package (SiGit.Code, installed with dotnet tool install --global SiGit.Code) is the
odd one out among the language registries: npm and PyPI publish one artifact per platform, but a
.NET tool is a single package, so nuget/sigit/ bundles all six binaries under
native/<os>-<arch>/ and a small managed shim (Program.cs) execs the right one. That shim leaves
stdin/stdout/stderr unredirected on purpose, since siGit Code chooses TUI or ACP mode by testing
whether stdin is a TTY. It also sets RollForward=Major: it targets net8.0, and without that a
machine carrying only the .NET 10 runtime installs a tool that refuses to start.
Bundling every target means the package is large, so release-nuget.yml fails the pack job if the
.nupkg crosses nuget.org's 250 MB limit. At v1.5.1 it lands around 160 MB. If a future release
trips that check, the fix is not to drop targets but to split into RID-specific tool packages
(.NET 10's DotnetToolRidPackage), which ship one binary per platform the way npm already does.
That also needs a fallback package for pre-.NET-10 SDKs.