React window (ui/) shadowcode CLI / TUI / MCP clients
│ Tauri IPC invoke("api") │ private Unix socket
└──────────────┬────────────────────────┘
▼
Service (native/core/src/service.rs) request router: /api/…
▼
Engine (engine.rs) jobs, queue, approvals, events, trust gate
▼
Runtime facade (runtime.rs) Vendor(vendor) | Local
┌────────┴──────────────────────────┐
▼ ▼
Vendor adapters (cli_agent/*) Native agent loop (engine + tools/)
codex app-server · claude -p · ShadowCode tools, permissions,
cursor-agent acp · checkpoints, web tools
agy_acp_server.par (Antigravity) · │ │
grok agent stdio ▼ ▼
(vendor runs its own loop) local_engine.rs → OpenRouter
local_runtime.rs chat completions API
managed llama-server (api:openrouter:*,
(127.0.0.1) openrouter.rs)
One executable (shadowcode, crate shadowcode-desktop in src-tauri/) runs the desktop window and
the CLI. The engine lives in the shadowcode-core crate (native/core) and
runs in-process with the window. There is no HTTP server between them.
- Desktop (
src-tauri/src/main.rs): hosts the React build (ui/dist, embedded) in the system WebKit webview and exposes one IPC command,api, which forwards{method, path, body}to theService. The UI contract is docs/API_CONTRACT.md. A window can also attach to an engine already running in a headlessserveor TUI process. Closing an attached window leaves that engine's work running. - Service (
service.rs): routes requests (/api/picker,/api/accounts/*,/api/openrouter/*,/api/local-models/*,/api/jobs, sessions, review, config).dispatchparses a request into aCalland hands it, by the segment after/api/, to one route module underservice/(sessions,jobs,workspace+git,worktrees,settings,accounts,model_catalog,goals,background,extensions,compare,commands,memory,feed,second_opinion). Bodies are typed structs whoseText/Flag/Loosefields read absent or mistyped values the way the untyped API did. Synchronous handlers run on tokio's blocking pool. The CLI reaches the same service throughcontrol.rs, a Unix socket in/run/user/<uid>/shadowcode/with peer-credential checks. It opens no TCP listener. At startup it removes sockets left behind by dead engines. - Engine (
engine.rs): owns jobs. Each workspace runs one active job plus queued follow-ups. Second opinions (second_opinion.rs: reviews of staged or task changes, another model's view of an answer) are ordinary read-onlyreviewjobs in hidden conversations, queued like follow-ups.start_with_contextis the single entry point for the desktop, CLI, goals, MCP and workflows. It enforces workspace trust, the offline refusal of cloud routes, read-only mode for Plan/Review, and handoff consent. All of these checks run before a job row is written. - Runtime facade (
runtime.rs): a two-variant enum,Vendor(Vendor)orLocal, that dispatches availability, model discovery, capabilities (vision, tools, whether approvals reach ShadowCode, cloud or local), resume, cancel and usage. Provider protocols stay inside the adapters. - Vendor adapters (
cli_agent/):codex.rs(app-server JSON-RPC, plus anexecfallback),claude.rs(stream-json),acp.rs(Cursor, Grok and Antigravity).antigravity_server.rsinstalls Google's ACP agent server on request (pinned version, size and SHA-256), prepares its private profile and per-launch temp directory, and builds its launch arguments. Each adapter is a line-oriented state machine.runner.rsowns the process, stdin/stdout, approvals, stall detection and cancellation. The vendor runs the agent loop with its own tools and sandbox. ShadowCode's tools are never injected into it. - Rulebook (
rulebook/): the user's profile (~/.config/shadowcode/profile/:AGENTS.md, skills, commands, agents, Git imports) merged with the project's own files, with per-item switches. The native loop gets it in the system prompt; each vendor adapter gets it through the vendor's own per-run mechanism (LaunchOptions::rulebook: Claude--append-system-prompt-fileand--plugin-dir, CodexdeveloperInstructions, a labelled first-prompt block for ACP). Nothing is written to vendor folders. See docs/RULES_AND_SKILLS.md. - Native agent loop: used for local GGUF rows, OpenRouter rows
(
openrouter.rs: key check, cached model list, picker rows) and configured OpenAI-compatible endpoints. It handles context accounting and compaction, tool calls throughpermissions.rs, file checkpoints, verification, web tools (web.rs) andview_imagefor vision models.
cli_agent/catalog.rs holds one VendorCatalog per engine. It probes each
vendor with official interfaces only:
| Vendor | Probe |
|---|---|
| Codex | app-server account/read, account/rateLimits/read and model/list (codex_probe.rs) |
| Cursor, Grok | ACP initialize, authenticate and session/new (acp_probe.rs); no prompt is sent |
| Claude | claude auth status and the model aliases in claude --help |
| Antigravity | ACP initialize, authenticate (oauth-personal) and session/new against the agent server, with a no-op BROWSER; a printed Google sign-in link means Sign in. Models come from the session's model config option |
- Caching. Results are cached with a 5-minute freshness window and backoff on failure. Offline mode starts no probe.
- Picker rows.
picker_rowsbuilds one row per discovered model. The local catalog (local_engine.rs) adds GGUF rows, andopenrouter.rsadds the API keys rows once a key is saved (from a cached model list, refreshed in the background when older than 6 hours)./api/pickerreturns all three. Row IDs are stable routing IDs:cli:<vendor>[:<model>],local:gguf:<hash of the canonical path>orapi:openrouter:<slug>. Display names are never used for routing.
- Snapshots.
cli_agent/usage.rsmodels what a provider reports: limit windows, quota pool, plan, credits when stated,limit_reached, and the refresh time. Anything not reported is left as unknown. Codex rate limits come from probes and fromaccount/rateLimits/updatedpushes during a turn. Each push emitsusage.updated. - Storage. Raw official payloads go into the
usage_snapshotstable, keyed by vendor, account and pool. After a restart they appear as Last checked … until the next probe. A snapshot older than 30 minutes is marked stale. Disconnect deletes the vendor's rows. - Database. SQLite
user_versionis 27 (store.rs). Opening an older database first copies it toshadow-agent.pre-native-<id>.sqlite(mode 600) with the SQLite backup API. Migrations then run forward in order inside one transaction. A database from a newer version is refused from its file header before anything is written, naming the version that last opened it (native_metaapp_version).tests/upgrade_fixtures.rsopens a profile written by every release since 0.28.0. Backups, scheduled restores and resets, and repair are indata.rs(DATA.md). The database runs in WAL mode behind one connection lock; async code does writes and large reads throughStore::run(tokio's blocking pool), so a slowfsyncnever stalls a worker that is streaming a model reply. - Per-conversation state. Execution targets and vendor session IDs are
session_metarows (execution_target,native_session:<vendor>). The per-project default is anative_metarow (execution_target:<workspace>). Everynative_metakey is built instore/keys.rs. JSON documents there (compare records, per-project compare indexes and scoreboards) are changed only inside oneBEGIN IMMEDIATEtransaction (Store::meta_transaction); a compare record carries a revision, so a stale save from a second process is refused instead of overwriting it or counting a run twice.
- Resolve.
local_engine::runtime_candidateslooks forllama-serverin this order: the configuredlocal_engine.llama_binary, then the runtime bundled next to the executable if it is newer than the managed copy (compared by thebuilt=line inCOMMIT), then~/.local/lib/shadowcode, then the bundle, thenSHADOWCODE_LLAMA_SERVER. It never usesllama-clior a barePATHlookup. - Verify. The runtime counts as ready only after
llama-server --versionsucceeds. Devices come from--list-devices, cached per binary path, size and mtime, plus/proc/meminfo. - Inspect.
gguf.rsreads the header for architecture, trained context, chat template and per-layer KV geometry.local_engine::plan_contextpicks the largest context that fits the GPU, otherwise RAM. - Load.
local_runtime.rsstarts one server with--host 127.0.0.1, a free port,--no-webui --jinja --ctx-size N --parallel 1,--mmprojif a projector is paired, and-ngl 999if the plan fits the GPU. It passes a fresh 32-byte key asLLAMA_API_KEYin a cleared environment. The server runs in its own process group withPR_SET_PDEATHSIG. Its stderr drains into a 16 KB ring. Loading can be cancelled and waits up to 90 s for/health. If the server exits early on the GPU, it is retried once with--device none -ngl 0. - Lease. A task holds a lease on the loaded model. While any lease is held, loading another model or unloading is refused.
- Stop. Unloading, swapping or shutting down sends SIGTERM to the process group, then SIGKILL after 5 s.
ollama_store.rs reads Ollama manifests and blob paths. It never writes to
the store.
- Vendor CLIs are spawned in the workspace in their own process group with
PR_SET_PDEATHSIG(SIGKILL)andkill_on_drop. Provider API-key variables are removed from their environment. The Antigravity server also runs without Google cloud-project variables, withGEMINI_HOMEset to ShadowCode's private profile andTMPDIRset to a per-launch directory that is removed afterwards. Cancelling sends SIGTERM to the group, then SIGKILL. A run with no output line forstall_timeout_sec(default 900) fails. - Login and logout commands (
cli_agent/auth.rs) run as supervised children: one login per vendor, cancellable, stopped after 10 minutes. Antigravity's Connect starts the agent server and sendsauthenticate; its Disconnect deletes the private profile. - Probes use short timeouts. Codex probes and doctor commands run with a cleared, allow-listed environment.
- Restart recovery. A profile lock (
native.lock) prevents two engines from owning one profile. On restart, unfinished jobs are markedinterrupted. Shell commands and file edits are never replayed.
Every job writes durable events to SQLite with monotonically increasing IDs. A bounded broadcast channel (1024 entries) only wakes up listeners. The UI reads committed rows from its last cursor, so a missed wakeup or a reload never duplicates or loses output.
| Event group | Events |
|---|---|
| Tools and approvals | tool.started / tool.completed (redacted in storage; web tools add sources), approval.requested / approval.resolved, command.completed |
| Output | model.stream_end (a streamed reply is complete, so the final result doesn't repeat it; vendor turns send it too) |
| Routing and handoff | routing.selected (with inference: cloud|local), vendor.session, agent.handoff, model.switched |
| Usage and limits | usage.updated, limit.reached |
| Results | checkpoint.updated, checkpoint.restored, files.changed, verification.summary, web.source |
| Accounts (not tied to a session) | account.login, account.login.done |
Job statuses end in completed, failed, cancelled, interrupted or
limit_reached.
- Shell.
ui/src/App.tsxonly wires hooks to components. Behaviour is inui/src/hooks/:useNavigation(startup, opening conversations and projects, trust),useConversation(the job event stream and history pages),useFeed(approvals and jobs),useTaskActions(sending, slash commands, consent, plan-limit continuation),useJobControls,useCompare,useShortcuts(one keydown listener),useTheme,useStickyScrollanduseDrawerMemory. Layout is inui/src/components/shell/(TopBar,Stage,ChatView,TranscriptRows,ComposerDock,StatusBar,AppDialogs). - Push, not polling. The desktop shell forwards every engine broadcast as
shadowcode:events {session_id, type}.useFeedreadsGET /api/feed(pending approvals and the job list) only for the types it lists (approval.*,job.changed,agent.*), with a 15 s backstop, and keeps unchanged arrays so nothing re-renders for them. - Rendering. Transcript rows carry stable keys (message, call or event
ids,
lib/rowKeys.ts) and are memoized per row; long pages render their latest 150 rows first. The elapsed timer ticks in its own component. Task summaries count changed lines for every file in onePOST /api/workspace/diffstat. - Picker and settings.
ui/src/components/UnifiedPicker.tsxis filled only byGET /api/picker. The settings pages are incomponents/settings/: Accounts (vendors, the Antigravity install and the OpenRouter key), Local models, Permissions & network, Appearance, Advanced and About. - Consent and progress.
ConsentDialog.tsxanswers theneeds_consentreply.ActivityTimeline.tsxandTaskSummary.tsxare built from recorded events. - Transport.
ui/src/lib/transport.tshas a single transport, Tauri IPC. A fake engine is honoured only in builds made withVITE_SHADOW_TEST_TRANSPORT=1, which the Playwright suite uses.e2e/check-bundle.mjschecks that the production bundle doesn't contain it. - Markdown.
components/Markdown.tsxrenders model output without raw HTML, remote images or non-http(s) links.
The AppImage and the deb both contain the executable with the embedded UI and
the managed llama.cpp runtime in usr/lib/shadowcode. The runtime uses
relative $ORIGIN links and NOTICES/. scripts/check-native-package.mjs
verifies both packages. scripts/install-appimage.sh installs the AppImage's
runtime into ~/.local/lib/shadowcode. See docs/RELEASING.md.
scripts/native-deb.mjs finishes the deb for distributions (copyright,
changelog, manual page and completions from shadowcode manpage and
shadowcode completions, icons, stripped runtime, libc dependency); see
docs/DISTRIBUTING.md.
updates.rs serves Settings › About (/api/about) and the update notice
(/api/updates). It detects the install type from the package format Tauri's
bundler writes into the executable, the AppImage environment and the
executable's path. At most once a day, when the build, the system
policy (/etc/shadowcode/policy.yaml, <prefix>/share/shadowcode/policy.yaml),
updates.check and the network mode allow it, the window's
GET /api/updates?auto=1 asks GitHub's latest-release API. The request has a
fixed User-Agent and no identifiers; the answer is kept in
update-check.json in the state folder. Nothing is downloaded.