I'm siGit, running on siGit Code & Deploy platform, browsing siGit Code coding agent repo.

1 # AGENTS.md
2
3 This file provides guidance to AI coding agents when working with code in this repository.
4
5 ## What this is
6
7 `sigit` ("siGit Code") is a single Rust binary: a local-first AI coding agent that runs LLM
8 inference on-device (via the `onde` crate / GGUF models) or against a hosted/OpenAI-compatible
9 endpoint. It exposes itself two ways from the *same* binary, chosen at startup by whether stdin
10 is a TTY:
11
12 - **ACP mode** (stdin not a TTY): speaks the Agent Client Protocol over stdio for editor
13 integration (Zed, VS Code ACP Client). Cross-platform.
14 - **Interactive terminal mode** (stdin is a TTY): a full-screen ratatui chat UI. **Unix-only** —
15 it relies on fd redirection to keep logs out of the TUI, so Windows gets ACP mode only.
16
17 Before the TTY/ACP split, `main` also dispatches the account subcommands `sigit login`,
18 `sigit logout`, `sigit whoami` (see `crates/cli/src/main.rs` `main()`).
19
20 ## Working in this repo
21
22 **IMPORTANT — branch naming:** Prefix every working branch with `feature/` (new functionality)
23 or `fix/` (bug fixes) — never a tool- or agent-name prefix like `claude/`. Name the branch after
24 the *changes it contains*, as a short, descriptive, kebab-case slug that is self-explanatory
25 from the name alone (e.g. `feature/tool-permission-system`, `fix/glob-mtime-sort`), never after
26 a task, ticket, or session id (not `feature/task-q003hm`).
27
28 **IMPORTANT — pull request target:** Always open pull requests against the `development` branch,
29 never `main`. `main` is release-only; `development` is where day-to-day work integrates, and the
30 release merge (see the `sigit-code-release` skill) is the one thing that ever puts commits on
31 `main`.
32
33 This rule needs help to hold, because the repository's default branch is `main`: both
34 `gh pr create` and the GitHub web form pre-select it, so a PR lands on the wrong base unless the
35 base is passed explicitly. Pass it every time, and check that it took:
36
37 ```sh
38 gh pr create --base development --head <branch>
39 gh pr view <number> --json baseRefName
40 ```
41
42 Retargeting a PR that was opened against `main` costs nothing before it merges:
43 `gh pr edit <number> --base development`. After it merges it costs real work — the change sits on
44 `main` while `development`, which the next release is cut from, does not have it, and someone has
45 to carry it back by hand. PR #65 went in that way.
46
47 **IMPORTANT — branch off `development`:** Start every working branch from the latest
48 `origin/development`. Exception: when new work *depends on* a feature branch that has not merged
49 yet (e.g. it builds on tools or APIs that branch introduces), it may be stacked on top of that
50 branch instead. When stacking: merge the base PR into `development` first, then rebase the
51 stacked branch onto `development` before opening its pull request, so each PR shows only its own
52 commits.
53
54 **IMPORTANT — run CI before pushing:** Run the full CI gate locally and confirm it is green
55 *before* pushing a branch or opening a pull request — never push work that fails these:
56
57 ```sh
58 cargo fmt -- --check # formatting
59 cargo clippy --tests -- -D warnings # lint (warnings are errors)
60 cargo test --locked # tests
61 ```
62
63 ## Agent assets layout
64
65 `.agents/` is the canonical home for agent assets; every other agent path in
66 the repo is a symlink into it, never a copy:
67
68 - `.agents/AGENTS.md` — this file, the project instructions. The root
69 `AGENTS.md` and `CLAUDE.md` are symlinks to it, so agents.md-standard tools,
70 Claude Code, and sigit itself (via `crates/cli/src/instructions.rs`, which prefers
71 `AGENTS.md` over `CLAUDE.md` in the same directory and therefore reads it
72 once) all load the same content. Edit this file; never replace the root
73 symlinks with real files.
74 - `.agents/skills/` — all project skills. `.claude/skills` is a symlink to it,
75 which is how both Claude Code and sigit itself (via `crates/cli/src/skills.rs`) discover
76 them. Add new skills under `.agents/skills/<name>/SKILL.md`, never as real
77 files under `.claude/`.
78
79 ## Workspace layout
80
81 The repo root is a Cargo workspace (`members = ["crates/cli"]`, with `crates/sigit-*` added as
82 they land). `crates/sigit-agent` is siGit Code as an `ed-acp` agent profile for apps that embed it (see
83 below). The `sigit` binary crate lives in `crates/cli`: its `src/` and `tests/` are what the
84 architecture section below calls `src/` and `tests/`, and it publishes as `sigit` from there.
85 Shared package fields and every dependency version are declared once in the root
86 `Cargo.toml` (`[workspace.package]`, `[workspace.dependencies]`) and inherited with
87 `workspace = true`. Build outputs are unchanged: `target/<triple>/release/sigit`. Release
88 workflows build and publish with `-p sigit`.
89
90 ## Build / test / lint
91
92 ```sh
93 cargo build # debug build
94 cargo build --release # release binary at target/release/sigit
95 cargo run -p sigit # launches interactive TUI (stdin is a TTY)
96 cargo test # CI runs: cargo test --locked --target <target>
97 cargo clippy --tests -- -D warnings # CI gate: clippy is -D warnings on all 4 targets
98 cargo fmt -- --check # CI gate (edition 2024)
99 ```
100
101 CI (`.github/workflows/ci.yml`) runs fmt + clippy + test across four targets:
102 `aarch64-apple-darwin`, `x86_64-apple-darwin`, `x86_64-unknown-linux-gnu`,
103 `x86_64-pc-windows-msvc`. Clippy is `-D warnings`, so warnings fail the build.
104
105 Run a single test: `cargo test <test_name>`.
106
107 ## Critical platform constraint: `#[cfg(unix)]` dead code
108
109 The interactive client is `#[cfg(unix)]`-only. The `InferenceBackend` seam (`backend.rs`) and
110 provider resolution (`provider.rs`) are consumed by both the interactive client and the ACP
111 server, but several of their items are reached only through the Unix-only interactive paths, so
112 the dead-code lint is suppressed *on non-Unix targets only*.
113
114 Consequence: code can pass clippy on macOS/Linux but fail on the Windows target (or vice versa).
115 When touching `backend.rs`, `provider.rs`, or the interactive path, keep the `cfg` gates intact —
116 don't "fix" an unused-warning by deleting code that's live on Unix.
117
118 ## Architecture
119
120 The agent loop is backend-agnostic. The flow: a turn (messages + tool specs) goes to an
121 `InferenceBackend`, which returns assistant text and/or tool calls; the loop executes tools and
122 feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete backend.
123
124 - **`crates/sigit-agent`** — siGit Code as a library for hosts that embed the agent (sigit-app
125 serves it in-process with `ed_acp::serve` over `Channel::duplex`, through `ed-acp-tauri`).
126 `SiGitCode` is the `ed_acp::Profile`: the workspace tools (or `SiGitCode::without_shell()` for
127 a host that cannot start processes), `/init`, and `SiGitAuth` as its `AuthProvider`.
128 `SiGitAuth` is the browser sign-in `sigit` runs in an editor (`browser_auth`, public OAuth
129 client `sigit-code-cli`, PKCE) over the same stored session (`credentials`); the CLI imports
130 both from here so there is one OAuth client. A host calls `credentials::use_config_dir` with
131 its own data directory, which is inside the container in a sandboxed app, so the app and the
132 CLI never share or overwrite a session. `llm_config()` is siGit Code Cloud
133 (`SIGIT_CLOUD_URL`, default `https://sigit.si/api/v1`) with the stored token as bearer.
134 `prompt.rs` holds `SYSTEM_PROMPT` and `INIT_PROMPT`; the CLI imports them. The official MCP
135 server reaches a session through `official_mcp_servers()` in the host's `mcpServers`, not as
136 a process-wide built-in: a shared server would keep its first bearer across a sign-out. It
137 depends on `ed-acp` 1.3.0 from crates.io (the release that carries `Profile::auth`; see the
138 root `Cargo.toml`). `release-crates.yml` publishes it before `sigit`.
139 - **`crates/cli/src/main.rs`** — entry point, mode dispatch, the full ACP `Agent` impl (session lifecycle:
140 new/load/fork/prompt/cancel, config options, slash-command advertisement), and the `SYSTEM_PROMPT`.
141 ACP session state owns its roots, conversation, and selected model even though the process has
142 one working directory and one live backend slot; activating a thread parks and restores all
143 three. A thread on an HTTP backend keeps a backend of its own (`InferenceBackend::fresh`,
144 `SessionState::remote`), so its turn can wait on the endpoint while another thread is
145 installed. Unknown session ids are rejected instead of silently borrowing the active thread's
146 cwd. Prompt cancellation takes no lock, which lets a client cancel a turn whatever it holds.
147 `$/cancel_request` is the other way to stop a request, naming it by JSON-RPC id. The SDK only
148 records it; a handler has to read `responder.cancellation()`. The prompt handler does and calls
149 `cancel_turn`, the same path `session/cancel` takes, so the prompt still answers with a
150 `cancelled` stop reason. `authenticate` is dropped instead and answers `-32800`. The other
151 handlers ignore it, which the spec allows.
152 `session/load` replays the saved history as `session/update` (`history_replay_updates`):
153 user text and its image and audio parts, reasoning as a thought chunk under the reply's id,
154 and each tool call folded together with its result.
155 Message chunks carry a `messageId`: each inference round is one message, so every chunk of
156 it, reasoning included, shares `StreamedReply::message_id` and a tool round starts a new id.
157 A standalone notice (`send_assistant_message`) gets an id of its own, and replayed messages
158 get fresh ones; ids are random, so none is stored in history.
159 The Inference (local/cloud) config option is a `boolean` for a client that advertises
160 `session.configOptions.boolean` (read into `CLIENT_BOOLEAN_CONFIG_OPTIONS` in `initialize`)
161 and a two-value select for any other; `handle_set_session_config_option` takes either value.
162 `session/load` and `session/resume` share `restore_session`; the only difference is that
163 resume must not replay the history as `session/update`. `session/close` is the one place a
164 `SessionState` is dropped. It runs in two halves: `begin_close` signals the session's turn
165 from the dispatch loop (the turn holds the session's lock, so the signal cannot wait for it) and
166 marks the id in `closing_sessions`, which makes `handle_prompt` cancel a prompt that was
167 still queued; `handle_close_session` then runs under the session and workspace locks and
168 removes the state, the permission grants and the background commands
169 (`tools::kill_session_tasks`). It leaves `session_store` alone, so a closed thread still lists
170 and reopens. `session/delete` is the one that removes it from disk: it runs the same two halves
171 (`begin_close`, then `handle_close_session` from `handle_delete_session`) and then
172 `session_store::delete`, and succeeds for an id that is already gone. The
173 `SYSTEM_PROMPT` bakes in smbCloud-specific context the agent should use when the repo is clearly
174 smbCloud, and stay general otherwise.
175 - **`crates/cli/src/backend.rs`** — the `InferenceBackend` trait and neutral types (`ToolSpec`, `ToolCall`,
176 `ToolResult`, `TurnResult`). Two impls: `LocalBackend` (on-device via `onde::ChatEngine`) and
177 `OpenAiBackend` (any OpenAI-compatible HTTP endpoint). A `BackendError` is user-facing: the
178 ACP prompt handler passes it to the client verbatim and the editor puts it in an error banner,
179 so `describe_api_error` unwraps the OpenAI `{"error":{"message":…}}` envelope and shows that
180 message on its own, falling back to the status only when there is nothing to unwrap. An
181 endpoint can also fail *after* the response is open, reporting it as a `data:` frame holding
182 the same envelope; that frame has no `choices`, so `consume_stream` has to check for it
183 explicitly or it parses as an empty chunk and the turn ends looking like an empty answer.
184 `TurnResult::context_tokens` carries the endpoint's token count (prompt plus completion) up
185 to the loop, which sends it as a `usage_update` after each model response, with the
186 model's picker `context_window_tokens` as `size`; with no count (always on-device) the
187 history is estimated instead. A streamed request asks for the count with
188 `stream_options.include_usage`, and an endpoint that rejects the option is asked again
189 without it and never asked again (`stream_usage_refused`).
190 `TurnResult::finish` carries the endpoint's `finish_reason` up to the loop, and
191 `stop_reason_for` in `main.rs` turns it into the ACP stop reason: the tool-round cap is
192 `max_turn_requests`, `length` is `max_tokens`, `content_filter` is `refusal`. ACP defines a
193 refusal as a turn the next prompt will not include, so `handle_prompt` restores the history
194 it snapshotted before the turn instead of only relabelling the response.
195 The round that reaches the cap (24, or `SIGIT_MAX_TOOL_ROUNDS`) is followed by one forced
196 text reply with no tools. The model cannot see that the tools are gone, so
197 `round_cap_note` is appended to that round's last tool result, and the turn always closes
198 with `round_cap_stop_message`. Without both, the thread stops on the model announcing a
199 step that never runs (issue #120).
200 Some models write tool calls into content as text; `crates/cli/src/inline_tool_calls.rs` recovers the
201 well-formed ones. That covers the `<invoke>` shape (and its leaking `]<]minimax[>[` token, which is stripped) and
202 DSML in every envelope/closer spelling. A block that doesn't parse (or never closes) is dropped from both the reply
203 and history, and `OpenAiBackend::complete` retries once with a note telling the model the call
204 didn't run. Leaving the raw block in history makes the model invent `<function_results>` later.
205 What that scanner lets through then goes through `crates/cli/src/harness_markup.rs`, which removes
206 markup the model makes up in the harness's shape (issue #122): a `<system_warning>`-style
207 block (any `system_*`/`system-*` tag) is dropped whole, from the reply and the history, and
208 other underscore-named tags like `<Option_Picker>` lose the tags but keep their text. Code is
209 never touched, and an opening tag only counts at the start of a line, since prose writes
210 placeholders like `<repo_url>` the same way. It has to run after the inline-call scanner,
211 because `<tool_call>` matches its underscore rule.
212 `crates/cli/src/repetition.rs` guards the same stream against a reply that degenerates into a loop over a
213 tiny vocabulary (issue #123): a window of 200 words with at most 40 distinct ones, fenced code
214 excepted, makes `consume_stream` stop reading, truncate the text to where the loop began (so
215 history never holds the junk), tell the user, and finish with `FinishReason::Repetition`
216 (`EndTurn` in ACP). `complete` then retries once with `REPETITION_RETRY`, like the malformed
217 tool call retry. Only the streaming path is guarded; `consume_json` and `LocalBackend` are not.
218 Image attachments: ACP fixes `promptCapabilities.image` for the whole connection, while the
219 model can change on any turn, so the capability is always advertised and the decision is made
220 per prompt. `InferenceBackend::accepts_images` answers for the active model (on-device: no;
221 remote: `provider::model_accepts_images`, which reads the hard-coded `IMAGE_TIERS` for
222 `onde-*` ids and says yes for a user's own endpoint). `images_for_turn` in `main.rs` either
223 passes the images to `send_message_with_images` or drops them with a note to the user and a
224 bracketed note to the model. A message with images is stored in history as OpenAI content
225 parts and stays that way; `OpenAiBackend` strips the images per request (`without_images`)
226 when its model cannot read them, which is what makes a mid-thread switch to a text-only tier
227 safe. Read history text through `backend::message_text`, never `content.as_str()`.
228 `IMAGE_TIERS` mirrors onde-cloud's `IMAGE_CANDIDATES`; update both together.
229 Audio attachments take the same path (`promptCapabilities.audio`, `accepts_audio`,
230 `audio_for_turn`, `without_audio`), sent as OpenAI `input_audio` parts. No cloud tier
231 takes audio, because onde-cloud drops `input_audio` parts when it parses a request, so
232 `provider::model_accepts_audio` says no for every `onde-*` id and yes for a user's own
233 endpoint. If onde-cloud ever routes audio, give it a tier table like `IMAGE_TIERS`.
234 - **`crates/cli/src/provider.rs`** — decides *which* backend serves inference. Resolution order, first match
235 wins: (1) override via `OPENAI_BASE_URL`+`OPENAI_API_KEY` or active profile in
236 `~/.config/sigit/providers.toml`; (2) siGit Code Cloud when logged in; (3) on-device.
237 - **`crates/cli/src/tools.rs`** — agent tool schemas + execution: `read_file`, `create_directory`,
238 `list_directory`, `search_files`, `glob`, `read_website`, `create_file`, `edit_file`,
239 `multi_edit`, `delete_file`, `run_command`, `command_output`, `kill_command`, `monitor_command`,
240 `write_todos`, `remember`. Add a tool in both the
241 spec list (`all_tools`) and the execute `match` (`execute_tool`). `monitor_command` waits on a background task in one call and reports what it has so far
242 through the `ToolProgress` seam (`register_tool_progress`; `main.rs` registers `AcpToolProgress`,
243 which sends a `tool_call_update` replacing the card's content; a no-op in the TUI and headless).
244 `run_command` also enforces
245 commit attribution: when a command creates a new commit that lacks the
246 `Co-Authored-By: siGit Code` trailer (`commit_co_author_trailer()`, which reads
247 `siGit Code v<version>-<acp|tui|headless>` from the surface `main` sets via `set_surface`), it amends the trailer in —
248 unless the commit already exists on a remote, which is never rewritten. Every child process it
249 spawns (`spawn_shell`, the `git` helpers, and `hooks.rs`) sets `stdin` to null and never
250 inherits it: in ACP mode sigit's stdin is the JSON-RPC pipe from the editor, so a command that
251 reads stdin both blocks forever and eats the client's next request, wedging the session past
252 any timeout. On Unix the shell also leads its own process group so the timeout kills the whole
253 tree, and stdout/stderr are drained on threads while the command runs — waiting first and
254 reading after deadlocks as soon as the output outgrows the pipe buffer. Also owns the `task`
255 tool: a nested agent loop in a fresh conversation, offered only when `subagent_available()`
256 (a subagent factory is registered — see `register_subagent_factory_for` in `main.rs`; on-device
257 registers a `None`-returning factory since onde has a single shared history). A subagent's
258 toolset is a hard-gated read-only allow-list (`SUBAGENT_TOOL_NAMES`) that is never expanded by a
259 configurable subagent type (see `crates/cli/src/subagents.rs`) — only ever narrowed — so a `.sigit/agents/*.md`
260 file can't grant itself `edit_file`/`run_command` and bypass the permission system. Also owns
261 `web_search`: a thin native wrapper around the official MCP server's Brave-Search-backed
262 `web_search` tool (`mcp__sigit__web_search`, implemented server-side in `sigit-si`'s
263 `WebSearchService` + `Mcp::Tools::WebSearch`), offered only when that MCP tool was actually
264 discovered (i.e. the user is signed in to siGit Code Cloud — see `web_search_available`).
265 Wrapping it natively rather than leaving it as a raw `mcp__*` tool matters for two reasons:
266 `permissions::classify` treats every `mcp__*` tool as mutating (an "ask" prompt on every call),
267 while `web_search` is read-only like `read_website`; and the raw delegate name is filtered out
268 of the assembled tool list (`is_web_search_delegate`) so the model sees one clean option, not
269 two names for the same tool. Execution still forwards verbatim to `mcp::call_tool` — no second
270 HTTP/JSON-RPC implementation.
271 - **`crates/cli/src/skills.rs`** — [Agent Skills](https://agentskills.io) support. Discovers skill
272 folders (each with a `SKILL.md`: YAML frontmatter `name` + `description`, then Markdown
273 instructions) from `.sigit/skills/` and `.claude/skills/` in every project root (see `crates/cli/src/workspace.rs`),
274 `$SIGIT_CONFIG_DIR/skills/`,
275 and `~/.claude/skills/`. Progressive disclosure: the discovery list (name + description) is
276 baked into the dynamically-built `skill` tool's description, and activating a skill (the model
277 calls `skill` with a name) loads the full `SKILL.md` body. The `skill` tool is appended in the
278 `*_as_specs`/`build_tool_specs` layer (not in `all_tools()`) so its description can be dynamic,
279 and only when at least one skill exists.
280 - **`crates/cli/src/commands.rs`** — user-defined slash commands. Discovers Markdown files (each an
281 optional YAML frontmatter block — `description`, `argument-hint` — followed by a prompt-template
282 body) from `.sigit/commands/` and `.claude/commands/` in every project root, `$SIGIT_CONFIG_DIR/commands/`,
283 and `~/.claude/commands/`. A subdirectory namespaces the command with `:`
284 (`.sigit/commands/git/commit.md` → `/git:commit`). Unlike skills there's no tool-call
285 indirection: invoking one works exactly like the built-in `/init` — `commands::render`
286 substitutes `$ARGUMENTS`/`$1..$9` in the body against whatever followed the command on the
287 input line, and the result is fed to the model as a normal turn through the ordinary tools and
288 permission checks. Resolution happens where each surface (`main.rs`/`chat.rs`) already
289 special-cases `/init`: an unrecognized slash command is tried against `commands::resolve_command`
290 before falling back to "unknown command". A custom command sharing a name with a built-in is
291 unreachable (built-ins match first in `parse_slash`) and is skipped when advertised to ACP
292 clients, with a warning logged.
293 - **`crates/cli/src/subagents.rs`** — configurable subagent types for the `task` tool. Discovers Markdown
294 files (YAML frontmatter `name` + `description`, optional comma-separated `tools:` allow-list,
295 then a Markdown body that becomes the subagent's system prompt) from `.sigit/agents/` and
296 `.claude/agents/` in every project root, `$SIGIT_CONFIG_DIR/agents/`, and `~/.claude/agents/`. Passing a
297 type's `name` as `task`'s `subagent_type` argument swaps in that system prompt and, if `tools:`
298 is set, narrows the offered toolset to its *intersection* with `SUBAGENT_TOOL_NAMES` — the
299 security-relevant narrowing logic lives in `tools.rs` next to that constant, not here; this
300 module only discovers and parses files. `SubagentFactory` (in `tools.rs`) takes the resolved
301 system prompt per call rather than baking one in at registration, so a single registered
302 factory serves both the default research subagent and every configured type.
303 - **`crates/cli/src/frontmatter.rs`** — shared "YAML frontmatter + Markdown body" parsing used by both
304 `crates/cli/src/skills.rs` (`SKILL.md`) and `crates/cli/src/commands.rs` (`.sigit/commands/*.md`).
305 - **`crates/cli/src/mcp.rs`** — [Model Context Protocol](https://modelcontextprotocol.io) *client*. Two
306 transports: **Streamable HTTP** (one JSON-RPC POST endpoint, `url` in `mcp.toml`; replies are
307 `application/json` or SSE) and **stdio** (`command` + optional `args`/`[server.env]` in
308 `mcp.toml`; sigit spawns the server and speaks newline-delimited JSON-RPC over its
309 stdin/stdout, stderr inherited into sigit's log). `url` and `command` are mutually exclusive —
310 both or neither is a config error, logged and skipped. Both transports run the same
311 `initialize`/`tools/list` handshake and forward `tools/call`. Discovery is best-effort at
312 startup (`mcp::init`, called from both branches of `main()`) and cached in a process-global so
313 the synchronous spec builders (`mcp::tool_specs`) and the async dispatch (`mcp::call_tool`) can
314 both read it; `/reload` does *not* re-run it, so config changes need a restart. stdio children
315 live for the process; a dead child fails calls with an in-band error string (no auto-restart).
316 Tools are namespaced `mcp__<server>__<tool>`, appended in the `*_as_specs`/`build_tool_specs`
317 layer and routed in `tools::execute_tool` via `mcp::is_mcp_tool`. Two servers are baked in:
318 the official server (`<cloud>/mcp`, default `https://sigit.si/api/v1/mcp`, always HTTP, authed
319 with the cloud session token) and the smbCloud CLI server (`smb --mcp`, stdio, added only when
320 the `smb` binary is on `PATH`; opt out with `smbcloud = false` in `mcp.toml` or
321 `SIGIT_MCP_SMBCLOUD=off`). A user-defined entry named `sigit` or `smbcloud` overrides the
322 corresponding baked-in one. Extra servers live in `mcp.toml` (global
323 `$SIGIT_CONFIG_DIR/mcp.toml` and project-local `.sigit/mcp.toml`). The stdio path is covered by
324 `crates/cli/tests/mcp_stdio.rs`, driven by the test-only `crates/cli/src/bin/mcp_stdio_stub.rs` helper binary
325 (excluded from the published crate via `exclude` in `Cargo.toml`). The baked-in official
326 server is *also* listed in the public MCP Registry as `si.sigit/sigit` — a **remote**
327 Streamable-HTTP listing owned by the [`getsigit/si`](https://github.com/getsigit/si) repo
328 (`server.json` at its root, published by its `release-mcp-registry.yml`), not this one. Because
329 it's a remote server, the registry's URL-match rule forces a domain namespace (`si.sigit` ↔
330 `sigit.si`) verified by a DNS TXT record, not the GitHub-OIDC scheme `smbcloud-cli` uses for its
331 package listing.
332 Separate from all of the above are the servers an ACP client passes in `mcpServers` on
333 `session/new`, `session/load` and `session/fork`. ACP requires agents to connect to the stdio
334 ones, and they belong to the session that named them, so they cannot live in the startup
335 global: `connect_session_servers` returns a `SessionServers` that `main.rs` keeps on
336 `SessionState` and installs with `set_session_servers` whenever a session becomes live, the
337 same way it swaps the roots. `tool_specs`, `call_tool` and `/mcp` read the startup servers and
338 then the live session's. A client-supplied server whose name is already taken by a startup
339 server is not connected, since both would claim the same `mcp__<server>__` prefix. Streamable
340 HTTP entries are connected too, with the URL and headers the client sent, because
341 `handle_initialize` advertises `mcpCapabilities.http`. SSE is not advertised (the MCP spec
342 deprecated it), so a conforming client never sends one and a stray entry is skipped.
343 An MCP server's `elicitation/create` is not forwarded to the ACP client as a form
344 elicitation: `ed-mcp`'s client handler declares no capabilities and has no hook for it, so
345 servers are never offered elicitation, and `rmcp` answers `ping` and refuses other server
346 requests itself. Forwarding needs that hook in `ed-mcp` first
347 (tracked upstream at `ondeinference/ed#33`).
348 - **`crates/cli/src/permissions.rs`** — tool permission policy. Every tool call passes through
349 `decision_for` before executing: read-only tools always run; mutating tools (and all
350 `mcp__*`/unknown tools) are governed by, in order: per-session plan mode (`/plan` — deny all
351 mutating tools with a present-a-plan message), session "always deny" and "always allow"
352 choices (deny checked first, both scoped by `session_grant_rule` and dropped with the session),
353 per-tool overrides and the default mode from `[permissions]` in `settings.toml`
354 (`allow`/`ask`/`deny`, default `ask`; `SIGIT_PERMISSIONS` env overrides the default). On `ask`,
355 the ACP path sends `session/request_permission` (allow once / allow for session / deny / deny
356 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
357 announced, because it sets the announced status: a call that will ask starts `pending`, the
358 permission request carries that call's own id, and an `in_progress` update follows approval.
359 That update also puts the card's title back, since the permission request overwrites it
360 with the full arguments. The Manual/Auto/Plan choice reaches an ACP client twice: as the
361 `sigit-permission-mode` config option, and as session `modes` with `session/set_mode`, which
362 the spec is retiring but some clients still render instead. Both are built from
363 `PERMISSION_MODES` in `main.rs` and read the same state, so whatever changes the mode has to
364 refresh both (`ConfigOptionUpdate` and `send_current_mode`). Note: ACP turn-affecting handlers run in `cx.spawn`ed tasks
365 so the dispatch loop can route the client's permission answer mid-turn — don't move them back
366 inline, and don't await client requests from inline handlers (deadlock). Two locks order them.
367 A per-session lock (`SiGitAgent::session_lock`) is held for a whole request, so requests on one
368 thread keep their order. `SiGitAgent::workspace_lock` guards what a session installs
369 process-wide (cwd, workspace roots, session MCP servers, the live backend slot, the engine's
370 conversation); lifecycle and config handlers hold it throughout, and `handle_prompt` holds it
371 through a `WorkspaceHold` that it releases while waiting on an HTTP endpoint or a permission
372 answer and retakes (reinstalling its session via `resume_workspace`) before any tool runs. An
373 on-device turn never releases it. Take the session lock first, never the other way round. A turn registers its `PromptCancellation` at
374 the top of `handle_prompt`, before it waits on either lock, and a `PromptRegistration`
375 guard clears it on every return, so a `session/cancel` that arrives while the turn
376 is still queued, switching threads, loading a model or running a slash command ends
377 it as `cancelled`. The checks sit after each of those waits. A cancel (and
378 `begin_close`) also raises the session's `tools::AbortSignal`, which the local
379 `run_command` poll loop and `client_terminal::Route::run` watch, so a running
380 foreground command is killed instead of finishing first. The signal is reset at the
381 top of each turn, not when a tool starts, so a cancel that lands during a permission
382 prompt still stops the command that follows. Permission-mode changes
383 (`session/set_mode`, and `session/set_config_option` for `sigit-permission-mode`) are
384 the exception to the lock order: the dispatch loop runs them with neither lock, because
385 they touch only state keyed by session (`permissions`) and have to land mid-turn. They
386 check the id against `sessions` (`-32002` when unknown) and build the options from
387 that session's own model (`config_options_for_session`), never `activate_session`. Unknown session ids are `-32002` (resource not found) everywhere,
388 and `authenticate` accepts only `AUTH_METHOD_ID`. Tool
389 execution, subagents included, still runs one session at a time.
390 - **`crates/cli/src/instructions.rs`** — project instruction files, the always-on counterpart to skills.
391 Reads `AGENTS.md` (the cross-tool [agents.md](https://agents.md) standard) and `CLAUDE.md`,
392 walking from the session cwd up to the repo root (nearest ancestor with `.git`, never above it),
393 plus a global file under `$SIGIT_CONFIG_DIR`. Files are ordered outermost-first so the deepest
394 (most specific) wins. In a multi-root project that walk is repeated for every root
395 (`load_workspace_instructions`). The combined block is injected via `session_context_message`
396 in `main.rs` — pushed as a system message at every ACP session entry point (new/load/fork +
397 model switch) and appended to the system prompt on the cloud and TUI-startup paths.
398 - **`crates/cli/src/workspace.rs`** — the directories the session treats as project roots. An editor can
399 open several at once (Zed calls it a multi-root project) and ACP carries the extras as
400 `additional_directories` on every session request; the headless CLI takes them as repeatable
401 `--add-dir` flags. A client only sends those extras to an agent that advertises
402 `sessionCapabilities.additionalDirectories` in its `initialize` reply, so that capability in
403 `handle_initialize` is what makes the rest of this reachable — without it Zed keeps the first
404 root, drops the others, and shows "This agent doesn't currently support multi-root workspaces".
405 The process still has one working directory, so the extra roots live in a
406 process-global here and `project_dirs()` returns cwd-first, extras after.
407 One process also serves every thread the editor has open, so that global
408 (with the cwd, the backend conversation, and the background-task owner in
409 `tools.rs`) always belongs to the *live* session: `main.rs` keeps a
410 `SessionState` per session id and `activate_session` parks the live one and
411 installs the requested one before any prompt or config change runs. Don't
412 read another session's roots from the global. Project-local
413 discovery reads it: skills, slash commands, subagent types, and instruction files all scan
414 every root. MCP is deliberately not on that list — `mcp::init` runs once at startup, before
415 any session exists, so a second root's `.sigit/mcp.toml` has nobody to tell. (The servers a
416 client names in `mcpServers` are a different thing and are per session; see `crates/cli/src/mcp.rs`.)
417 - **`crates/cli/src/client_fs.rs`** — file reads and writes through the ACP client. A client that
418 advertises `fs.readTextFile` / `fs.writeTextFile` in `initialize` serves `fs/read_text_file`
419 and `fs/write_text_file` from its buffers, so `read_file` sees unsaved changes and
420 `edit_file` lands in the open buffer instead of underneath it. `tools.rs` has no access to the
421 connection, so this is a seam like the one `mcp::call_tool` gives MCP: `handle_initialize`
422 registers a `ClientFileSystem` (`AcpClientFs` in `main.rs`, holding the connection), and
423 `execute_tool_impl` asks `route_for` before it dispatches `read_file`, `create_file`,
424 `edit_file` or `multi_edit`. Each of those tools is split into a parse step and a pure
425 render/apply step (`ReadFileCall`, `CreateFileCall`, `EditCall`) that the disk path and the
426 client path share, so the model gets the same result text either way. The writing tools also
427 hand back the file before and after (`tools::FileChange`, via `execute_tool_with_change`), which
428 `handle_prompt` sends as ACP `diff` content ahead of the result text. A permission request for
429 one of them carries the same diff, worked out by `preview_file_change` without writing anything
430 and before the workspace is released, since a relative path resolves against the session's cwd. The two capabilities are
431 independent: a client that only reads still gets its edits written to disk. The disk is the
432 fallback throughout: nothing is registered in the TUI or headless modes, a path outside the
433 session's roots is not routed, and a request the client fails or leaves unanswered for 30
434 seconds is retried on disk with a warning in the log. The client is only ever asked from
435 inside a spawned prompt turn, never from `handle_initialize` itself (deadlock, same as
436 permission requests). Existence checks and `create_file`'s parent directories still use the
437 disk. `SIGIT_CLIENT_FS=off` turns the routing off. Covered by `crates/cli/tests/acp_client_fs.rs`.
438 - **`crates/cli/src/client_terminal.rs`** — `run_command` in the ACP client's terminal. A client that
439 advertises `terminal` in `initialize` runs a foreground command itself (`terminal/create`, with
440 the platform shell as `command` and the whole command line as one argument), and
441 `exec_run_command_via_client` embeds the terminal in the tool call as `terminal` content, so the
442 user watches the output live and can stop the command from the editor. The seam is
443 `client_fs`'s: `handle_initialize` registers `AcpClientTerminal`, and `execute_tool_impl` asks
444 `client_terminal_route` before the blocking path. Embedding needs the tool call's id, which is
445 why `execute_tool_with_change` takes one. What `run_command` promises stays put: the 120 s
446 timeout ends in `terminal/kill`, the output is capped, and `CommitWatch` still amends the
447 co-author trailer, checking HEAD on disk before and after. Background commands stay local
448 (`command_output` and `kill_command` read their pipes), and so does a command whose directory
449 is outside the session's roots, since nothing says the editor's machine can see it. A
450 `terminal/create` the client fails means nothing ran, so the command runs locally; a failure
451 after that is reported as the tool's error instead, because running it again could repeat its
452 effects. The finished card keeps the terminal and drops the result text, which still goes out
453 as `raw_output`. `SIGIT_CLIENT_TERMINAL=off` turns the routing off. Covered by
454 `crates/cli/tests/acp_client_terminal.rs`.
455 - **`crates/cli/src/chat.rs`** — the Unix-only ratatui TUI. Loading-spinner phase then chat; uses
456 `tokio::select!` to multiplex terminal events with streaming tokens.
457 - **`crates/cli/src/headless.rs`** — non-interactive `sigit run` execution for scripts, CI, and Factory
458 clients. The legacy `-p` form reaches the same parser. Every new run gets a UUID session id;
459 `--resume <id>` restores that session through `session_store`, and `--output jsonl` emits a
460 structured session/delta/tool/result stream while logs stay on stderr. Headless permission
461 prompts collapse to denial unless the tool was pre-approved with `--allow-tool`. The backend is
462 the provider override, else the default cloud tier when local inference is off; `--model <tier>`
463 (validated by `provider::cloud_tier_named`, a tier or its `onde-*` id) picks a cloud tier
464 instead and wins over both.
465 - **`crates/cli/src/session_store.rs`** — durable conversations: one JSON-lines history file per session at
466 `$SIGIT_CONFIG_DIR/sessions/<id>.jsonl`, written atomically, restorable into either backend.
467 Each save also writes a `<id>.meta.json` sidecar naming the session's `cwd` and the extra roots
468 of a multi-root project. That sidecar is what makes a thread *listable*: ACP's `session/list`
469 (advertised as `sessionCapabilities.list`, handled by `handle_list_sessions` in `main.rs` — the
470 editor's "Import Threads" picker) must report an absolute `cwd` per session and may filter on
471 it, so a session without one is skipped there while still reopening by id through
472 `session/load`. A reply holds `SESSION_LIST_PAGE_SIZE` threads; `nextCursor` names the last
473 one on the page (`SessionListCursor`, modified time plus id) so a save or delete between two
474 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
475 spoke in leaves nothing behind. Each ACP save (`persist_session` in `main.rs`) is also sent to
476 the client as a `session_info_update` carrying a fresh `updatedAt`, plus the title when it
477 differs from the last one sent (`announced_titles`), so a new thread gets its title in the
478 sidebar without waiting for the next `session/list`.
479 - **`crates/cli/src/setup.rs`** — model cache location, local model discovery, selected-model persistence.
480 Must run (`setup_shared_model_cache`) *before* anything touches `ChatEngine`/`hf-hub`, since
481 those read env vars once at init.
482 - **`crates/cli/src/account.rs`** — siGit Code Cloud auth (`/login`, `/logout`, `/whoami`); authenticates
483 against the account API and stores a session token. Performs no console I/O.
484 ACP `logout` (the editor's sign out button, shown only because `handle_initialize` advertises
485 `agentCapabilities.auth.logout`) and `/logout` both go through `SiGitAgent::sign_out` in
486 `main.rs`. The request names no session, so `leave_cloud` moves the parked threads off their
487 cloud tier along with the live one and each of them gets a fresh model picker.
488 - **`crates/cli/src/browser_auth.rs`** — browser sign-in, the path the editor's "Sign in to siGit Code"
489 button takes. sigit is a public OAuth client (client id `sigit-code-cli`, no secret, PKCE
490 S256) against the authorization server at `$SIGIT_API_URL/oauth`. Two ways the code gets
491 back: a loopback listener on `127.0.0.1:0` (the registration lists `http://127.0.0.1/callback`
492 with no port, and RFC 8252 §7.3 matching ignores the port, so one registration covers every
493 run), or the out-of-band URN, where the server renders the code and the user pastes it. The
494 resulting token goes into `credentials` like any other, and is served by `/api/v1/user` rather
495 than the deprecated `/api/v1/me` — OAuth tokens are rejected there. The scopes requested are
496 `user:read code:agent`; `code:agent` is what reaches `chat/completions` and the official MCP
497 server. Surfaces: ACP `authenticate` (loopback only, since a code cannot be pasted back; for a
498 client that advertises `elicitation.url`, `sign_in_via_client` hands it the authorize URL as a
499 URL-mode elicitation instead of launching a browser, ends the sign-in if the user declines, and
500 sends `elicitation/complete` once the code is redeemed; `authenticate` runs in a spawned task
501 so that answer can be routed), a bare `/login`
502 in either chat surface, and `sigit login` (which adds the paste fallback, plus `--paste` to
503 force it and `--password` for the old email/password prompt).
504 - **`crates/cli/src/credentials.rs`** — local session-token store (TOML, `0600` on Unix).
505 - **`crates/cli/src/models.rs`** — model-picker types shared across platforms.
506
507 Slash commands (`/help`, `/models`, `/skills`, `/agents`, `/commands`, `/mcp`, `/login`, `/logout`,
508 `/whoami`, `/reload`, `/plan`, `/permissions`, `/init`, `/clear`, `/status`) are advertised via
509 `advertise_commands` in `main.rs` and handled in both the TUI and ACP sessions. `/init` is
510 special: instead of replying directly it substitutes `instructions::INIT_PROMPT` for the user
511 text and runs a normal agent turn that explores the repo and writes (or improves) `AGENTS.md`
512 through the ordinary tools and permission checks. User-defined commands (see `crates/cli/src/commands.rs`)
513 get the same treatment via the `SlashCommand::Unknown` fallback path, so anyone can add their own
514 `/name` commands without touching the built-in command list.
515
516 ## Model cache (macOS)
517
518 On macOS the HF model cache lives in an App Group container shared with the siGit desktop app:
519 `~/Library/Group Containers/group.com.ondeinference.apps/models/`. Other platforms fall back to
520 `~/.cache/huggingface/`. The CLI reuses a model the desktop app already downloaded. First run
521 downloads a GGUF model (~1–2 GB) from Hugging Face.
522
523 ## Logging
524
525 In TTY (interactive) mode, *all* output — `log`, `tracing`, stray `println!` — is redirected to
526 `$TMPDIR/sigit.log` so the ratatui surface stays clean; the TUI holds a separate fd to the real
527 terminal. In ACP mode, stdout is reserved for protocol JSON and logs go to stderr. Control
528 verbosity with `RUST_LOG`.
529
530 ## Relevant env vars
531
532 `OPENAI_BASE_URL` / `OPENAI_API_KEY` (provider override), `SIGIT_API_URL` (account API base,
533 default `https://sigit.si`), `SIGIT_CLOUD_URL`, `SIGIT_CONFIG_DIR` (default `~/.config/sigit`),
534 `SIGIT_MODEL`, `SIGIT_MAX_TOOL_ROUNDS` (1 to 500, default 24; the tool-round cap of a
535 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
536 ACP client offers `fs/read_text_file` / `fs/write_text_file`), `SIGIT_CLIENT_TERMINAL` (`off` runs
537 `run_command` locally even when the ACP client offers a terminal), `SIGIT_MCP_SMBCLOUD` (`off` drops the baked-in
538 smbCloud CLI server), `SIGIT_MCP_OFFICIAL` (`off` drops the baked-in
539 server), `SIGIT_PERMISSIONS` (`allow`/`ask`/`deny` — overrides the default permission mode for
540 mutating tools; the escape hatch for clients without permission-request support),
541 `HF_HOME` / `HF_HUB_CACHE`, `RUST_LOG`.
542
543 ## Releasing
544
545 Version lives in `[workspace.package]` in the root `Cargo.toml`. Update `CHANGELOG.md` for releases.
546
547 Publishing splits into two groups. **Language registries** each get their own tag-triggered
548 workflow: `release-crates`, `release-npm`, `release-nuget`, `release-pypi`. The `npm/`, `nuget/`,
549 and `pypi/` dirs hold their wrapper-package templates.
550
551 npm and NuGet authenticate with OIDC (Trusted Publishing), so neither needs a stored token. On
552 npm that is configured per package, not per org: all seven `@getsigit/*` names carry a trusted
553 publisher pointing at `release-npm.yml`, and a package added later needs the same setup or its
554 publish step will fail. OIDC also requires npm >= 11.5.1 and Node >= 22.14.0, which is why the
555 workflow upgrades npm and why `.nvmrc` cannot drop below 22.
556
557 **OS package managers** all publish somewhere outside this repo and all need checksums from the
558 GitHub release, so `release-github` builds the binaries, attaches the assets, and then dispatches
559 `release-homebrew`, `release-scoop`, `release-winget`, and `release-aur`. A dispatch failure in one
560 is logged as a warning rather than failing the others, so an unconfigured channel does not block a
561 release. Their inputs live in `packaging/`.
562
563 The Windows binaries link the MSVC C runtime statically (`+crt-static` in `.cargo/config.toml`).
564 winget, Scoop, npm, PyPI and NuGet all ship the bare exe with no installer, so a dynamic build
565 that imports `VCRUNTIME140.dll` fails to load on a machine without the VC++ Redistributable
566 (`0xC0000135`, which is what failed winget's first validation).
567 `packaging/windows/check-static-crt.ps1` runs in CI and in `release-github` and fails the build if
568 a Windows exe imports the runtime again. Don't set a `RUSTFLAGS` env var in those workflows: it
569 replaces the config rustflags instead of adding to them.
570
571 Every release asset now carries a `.sha256` sidecar, not just the macOS Homebrew tarball. Scoop,
572 winget, and the AUR PKGBUILD each need one, and they consume the raw binaries rather than the
573 tarball. `release-github` also builds a `.deb` and `.rpm` per Linux target with nfpm
574 (`packaging/nfpm.yaml`), packaging the already-built binary rather than re-invoking cargo.
575
576 Re-pushing a tag fires every release workflow a second time, so each one is grouped by tag in a
577 `concurrency` block with `cancel-in-progress: false` — the duplicate queues behind the original
578 rather than interrupting a publish that is halfway through uploading. What keeps the queued run
579 from going red is the "is this version already published?" check each publishing workflow does
580 before it uploads. Those checks are load-bearing, not belt-and-braces: v1.5.6 published twice
581 because the crates.io one asked the API with curl's default User-Agent, which crates.io answers
582 with a 403, which read as "not published yet".
583
584 Three of these need credentials or a one-time manual step before they work:
585
586 - Scoop needs a `getsigit/scoop-bucket` repo and a `SCOOP_BUCKET_TOKEN` secret, mirroring the
587 Homebrew tap setup.
588 - winget files a Package Request issue first and then opens the manifest PR that resolves it,
589 authored by two separate classic PATs (replacing the old single `WINGET_TOKEN`):
590 `WINGET_ISSUE_TOKEN` (`public_repo`) for the issue and `WINGET_PR_TOKEN` for the PR.
591 `WINGET_PR_TOKEN` needs `public_repo` plus `read:org` and `read:discussion`: `public_repo`
592 lets `wingetcreate` fork `microsoft/winget-pkgs` and open the PR, and the later `gh pr edit`
593 that sets the PR body runs a GraphQL query resolving org/team and discussion fields that
594 fails with a scope error on a `public_repo`-only token. The PR token is passed as
595 `WINGET_CREATE_GITHUB_TOKEN` rather than `--token`, which wingetcreate warns can leak the
596 token into logs. `wingetcreate update` only works on a package that already exists in
597 `microsoft/winget-pkgs`, so the workflow checks the `manifests/g/getSigit/siGitCode` path
598 first and falls back to rendering `packaging/winget/*.yaml.in` and running
599 `wingetcreate submit` for a first submission. That fallback runs once, then every later
600 release takes the `update` path. Two non-obvious things in the update path: the `--urls`
601 arguments carry a trailing `|x64` / `|arm64` suffix that tells wingetcreate which installer
602 entry each URL replaces — without it, wingetcreate guesses from the file name, and
603 "sigit-win-amd64.exe" is not a spelling it recognises; and the first-submission render
604 uppercases the SHA256 (`tr '[:lower:]' '[:upper:]'`) to match the community validation
605 pipeline, which writes 64-hex checksums uppercase. The update path also passes
606 `--release-date`, `--release-notes-url`, `--submit`, and `--no-open` explicitly.
607 - The AUR needs `AUR_USERNAME`, `AUR_EMAIL`, and `AUR_SSH_PRIVATE_KEY`. It publishes `sigit-bin`
608 (a prebuilt binary) so Arch users are not compiling the on-device inference stack to install a
609 CLI.
610
611 `[profile.release]` sets `strip = "symbols"` because binary size is a distribution constraint, not
612 just a nicety. See the NuGet note below.
613
614 The NuGet package (`SiGit.Code`, installed with `dotnet tool install --global SiGit.Code`) is the
615 odd one out among the language registries: npm and PyPI publish one artifact per platform, but a
616 .NET tool is a single package, so `nuget/sigit/` bundles all six binaries under
617 `native/<os>-<arch>/` and a small managed shim (`Program.cs`) execs the right one. That shim leaves
618 stdin/stdout/stderr unredirected on purpose, since siGit Code chooses TUI or ACP mode by testing
619 whether stdin is a TTY. It also sets `RollForward=Major`: it targets `net8.0`, and without that a
620 machine carrying only the .NET 10 runtime installs a tool that refuses to start.
621
622 Bundling every target means the package is large, so `release-nuget.yml` fails the pack job if the
623 `.nupkg` crosses nuget.org's 250 MB limit. At v1.5.1 it lands around 160 MB. If a future release
624 trips that check, the fix is not to drop targets but to split into RID-specific tool packages
625 (.NET 10's `DotnetToolRidPackage`), which ship one binary per platform the way npm already does.
626 That also needs a fallback package for pre-.NET-10 SDKs.