Architecture
induscodeis the 100% Rust terminal-first AI coding-agent CLI, layered entirely on top of the indusagi framework. The OS launches it through theindusr/indusagirbins (both compiled fromsrc/bin/main.rs); embedders link the same subsystems asinduscode::<module>. This page maps how the agent's layers stack —bin/main→ launch (flag parse) → boot (pipeline + runner registry) → console / conductor → runtime — and catalogs every module with its role and a link to its doc.Unlike the TS and Python editions, the Rust edition is one merged product crate: the thirteen former
induscode-*library crates are now plain modules undersrc/, and the framework is the single publishedindusagiumbrella crate (replacing the deletedindusagi-*path deps).lib.rsis the library surface;bin/main.rsis the terminal binary; everything else ispub mod.
Table of Contents
- The Layer Stack
- The Two Faces: lib and bin
- Request Flow: bin/main to runtime
- The Launch Layer: flag parse
- The Boot Pipeline
- Runner Dispatch
- How a Session Is Assembled
- Module Map
- Where the Framework Begins
The Layer Stack
A turn flows down through induscode's own modules and lands on the framework runtime. Reading top to bottom, the UI/output sits at the edge, the conductor is the product core, and the framework agent loop produces the actual assistant message.
| Layer | Module | Role | Built on |
|---|---|---|---|
| Process entry | src/bin/main.rs |
The indusr / indusagir shim: brands off argv[0], installs the [MCP] noise filter, owns the no-session verbs (auth, signin/signout, package verbs, --list-models, --help/--version), and hands every real run to boot_run. |
tokio, stdlib |
| Launch | induscode::launch |
The typed command line: one declarative FLAG_SPECS table read by both the parser and the usage renderer, the @file attachment inliner, the model catalog printer, the multi-account credential vault, the three OAuth flows, the picker data. |
framework AI + PKCE/transport |
| Boot | induscode::boot |
The launch orchestrator: seeds an immutable BootContext, folds it through the five-stage pipeline, dispatches the resolved Runner, drains closables in reverse. |
induscode::core, all subsystems |
| Conductor | induscode::conductor |
The session core: wraps exactly one framework agent loop as a Conductor, projects loop events into a stable SessionSignal stream, persists a branchable transcript, retries faults, condenses on overflow. |
framework runtime |
| Capabilities | induscode::deck |
The tooling layer: provision_deck assembles the ToolBox the conductor consumes; a Capability projects into a runtime tool. |
framework capabilities |
| Console (UI) | induscode::console |
The interactive ratatui product shell: pure reducer, intent table, theme engine, overlays, slash catalog, mounted via mount_console. |
framework tui |
| Channels (output) | induscode::channels |
The non-interactive surfaces: the one-shot text/NDJSON channel (run_oneshot) and the framed JSON-RPC link protocol (session_ops + dispatch). |
conductor signals |
| Runtime | indusagi::runtime / indusagi::llmgateway |
The raw LLM conversation loop, model client, and content/message types the conductor drives. | framework runtime |
The conductor is the pivot: everything above it (console, channels) is a consumer of its signal stream and submit/snapshot/resume surface; everything below it (deck, framework agent) is assembled into it at startup by the boot layer's build_conductor.
The Two Faces: lib and bin
induscode has two faces, both at the root of src/:
| File | Role |
|---|---|
lib.rs |
The library surface — pub mod declarations for the thirteen subsystems (addons, boot, briefing, channels, conductor, console, core, deck, insight, launch, runtime_bridge, transcript_export, window_budget). Embedders reach everything as induscode::<module>. |
bin/main.rs |
The terminal binary — two [[bin]] targets (indusr, indusagir) share one async fn main() -> ExitCode, the Rust analogue of the TS {"indus": …, "indusagi": …} two-names-one-entry bin block. |
bin/main.rs does the work a session does not need before handing off:
- Brand off
argv[0]—resolve_brandtakes the basename, strips a trailing.exe, and matches it againstBIN_NAMES(["indus", "indusagi"]), falling back to the primaryBIN_NAMES[0]. - Install the
[MCP]noise filter —install_mcp_noise_filterrecords a single idempotent install; it is a no-op when the brand debug env var (INDUSAGI_DEBUG, derived viaindusagi::core::env_name("debug")) is set, so diagnostics are never hidden. - Route the argv tail —
routeowns the no-session verbs and the meta short-circuits, then hands every real run toboot_run.
async fn main() -> ExitCode is the only place an exit code reaches the OS. Returning ExitCode (rather than calling process::exit) lets the tokio runtime drain and destructors run, so a mid-TUI exit restores the terminal's raw/alt-screen mode cleanly.
// src/bin/main.rs (shape)
#[tokio::main]
async fn main() -> ExitCode {
let mut args = std::env::args();
let argv0 = args.next();
let brand = resolve_brand(argv0.as_deref());
install_mcp_noise_filter();
let rest: Vec<String> = args.collect();
route(brand, rest).await
}
route runs these checks in verbatim order before the boot handoff (each short-circuits to its own ExitCode without touching boot, because boot would drop them into the REPL):
| Order | Check | Handler | Notes |
|---|---|---|---|
| 1 | auth leading verb |
run_auth_subcommand |
login/refresh/status/help → framework run_auth_command; logout → agent signout; status → agent vault reader (reconciles both vault shapes). Intercepted before the global --help so auth --help renders auth help. |
| 2 | --help / -h |
render_usage + render_commands_section |
Banner generated from the single FLAG_SPECS table, plus a Commands: footer for the verbs that are not flags. |
| 3 | --version / -v |
print BRAND.app_name VERSION |
VERSION is env!("CARGO_PKG_VERSION") — single-sourced. |
| 4 | unknown leading flag | eprintln! + exit EXIT_USAGE (2) |
unknown_leading_flag checks the leading -… token against launch::flags::token_index. |
| 5 | signin / signout |
run_agent_credential |
The multi-account credential command over DiskAuthVault; signin defaults to --method api-key past the inert OAuth sentinel. |
| 6 | install/remove/update/list/config |
run_package |
Extension-package surface over the two-tier PreferenceStore. |
| 7 | --list-models [filter] |
print_model_catalog |
The launch catalog projection over the live indusagi::llmgateway cards. |
| 8 | everything else | boot_run(rest, production_io()) |
A bare prompt, -p, --json, --interactive — the session-bound launch. |
production_io() pairs a StdioSink (user text to stdout, diagnostics to stderr, each flushed, broken-pipe swallowed) with a StdinSource (one blocking stdin read per next_line, on a blocking thread so the runtime is not stalled).
Request Flow: bin/main to runtime
indusr "refactor the auth module"
│
▼
main() ──► resolve_brand(argv0) ──► install_mcp_noise_filter() ──► route(brand, rest)
│
▼
route(brand, rest) src/bin/main.rs
├─ auth verb? run_auth_subcommand(...) (exit; framework or agent vault)
├─ --help/-h? render_usage + commands (exit)
├─ --version/-v? BRAND.app_name + VERSION (exit)
├─ unknown -flag? exit 2 (EXIT_USAGE)
├─ signin/signout? run_agent_credential(...) (exit)
├─ package verb? run_package(...) (exit)
├─ --list-models? print_model_catalog(...) (exit)
└─ else boot_run(rest, production_io())
│
▼
boot_run(argv, io) src/boot/mod.rs
├─ seed_context(argv, io) → BootContext { workspace, brand, invocation, io, closables }
├─ pipeline::stages().run(seed)
│ locate-workspace → apply-upgrades → build-invocation → resolve-resources → select-runner
├─ runners::registry().select(&ctx.invocation).run(&ctx) → i32 exit code
└─ drain_closables(&ctx) (reverse, always)
│
▼
ReplRunner / OneShotRunner / LinkRunner src/boot/runners.rs
└─ build_conductor(ctx) src/boot/runners.rs
├─ resolve_model_id(--model ▸ saved ▸ authed ▸ catalog)
├─ build_session_tools (deck + MCP graft + addons)
├─ compose_session_briefing (--system / --append-system)
├─ resolve_permission_policy + GatedToolBox
├─ build_key_resolver (--account, on-read vault)
└─ Conductor::new(config, LoopDeps { store, plans_dir, fallback_model_id, diagnostics, … })
│ per turn
└─ Conductor drives ONE framework agent loop, re-emits SessionSignal
The console (interactive) or a channel (one-shot / link) subscribes to the conductor's signal stream and renders or serializes it. The framework agent loop produces the assistant message; the conductor re-emits a distinct, stable product signal stream so the app surface evolves independently of the framework's event loop.
The Launch Layer: flag parse
The launch crate owns the application command line, distinct from the framework's boot-routing parser. Its load-bearing decision: a single declarative table drives both the parser and the usage renderer so the help text and the parser can never disagree.
// src/launch/flags.rs
pub fn flag_specs() -> &'static [FlagSpec]; // 18 rows, 5 groups
pub fn token_index() -> &'static HashMap<&'static str, usize>;
The 18 flags, in declaration order (the FLAG_SPECS table is the single source of truth):
| Flag | Aliases | Kind | Group | Purpose |
|---|---|---|---|---|
--print |
-p |
Boolean | Output | Run a single request, print only the result, and exit. |
--json |
--rpc |
Boolean | Output | Speak the headless line protocol for a driving parent process. |
--interactive |
-i |
Boolean | Output | Force the interactive session even when a prompt is supplied. |
--model |
-m |
String | Model | Select the model, provider-qualified or bare (e.g. provider/name). |
--fallback-model |
— | String | Model | Model to switch to mid-turn when the selected model is overloaded (HTTP 529). |
--account |
— | String | Model | Authenticate the run with a named stored credential account. |
--thinking |
— | String | Model | Set the reasoning effort (off/minimal/low/medium/high/xhigh). |
--list-models |
— | String | Model | List the available models (optionally filtered by a substring) and exit. |
--cwd |
— | String | Context | Scope the run to a working directory (default: the current directory). |
--system |
— | String | Context | Replace the built-in system prompt with the given text. |
--append-system |
— | String | Context | Append extra text after the system prompt. |
--resume |
-r |
Boolean | Context | Pick a previous session to resume. |
--continue |
-c |
Boolean | Context | Continue the most recent session in this directory. |
--tools |
— | List | Tools | Allow only the named built-in tools (comma-separated or repeated). |
--no-tools |
— | Boolean | Tools | Disable every built-in tool for this run. |
--mcp |
— | List | Tools | Attach an external MCP server endpoint (comma-separated or repeated). |
--help |
-h |
Boolean | Meta | Show this usage and exit. |
--version |
-v |
Boolean | Meta | Show the version and exit. |
parser::read_invocation(&argv) is tolerant of unknown flags (the extension-flag escape hatch), so the unknown-leading-flag check lives in bin/main.rs, not the parser. It produces the launch Invocation whose mode: OutputMode is derive_moded: --json/--rpc → Rpc; --print && !--interactive → Json; else Text. See the full Launch subsystem for the parser internals, attachments, and the auth flows.
The Boot Pipeline
boot::boot_run(argv, io) (in boot/mod.rs) owns the headless launch arc. By the time argv reaches it, the no-session verbs and meta short-circuits have already been handled by bin/main.rs, so boot_run is the single session entry. It seeds a BootContext, folds it through the pipeline, dispatches a runner, and always drains closables on the way out — the only place a code reaches the OS being the ExitCode it returns.
// src/boot/mod.rs (shape)
pub async fn boot_run(argv: Vec<String>, io: BootIo) -> ExitCode {
let seeded = seed_context(argv, io);
let pipeline = pipeline::stages();
let ctx = pipeline.run(seeded).await; // immutable fold
let registry = runners::registry();
let code = registry.select(&ctx.invocation).run(&ctx).await;
drain_closables(&ctx).await; // reverse, always
ExitCode::from(code as u8)
}
pipeline::stages() builds a BootPipeline over five Stages. The fold is data, not control flow: each Stage::apply(ctx) -> BootContext returns a fresh successor (struct-update), never mutating in place. Adding a stage is a one-line edit in stages().
| Stage | Name | What it does |
|---|---|---|
| 1 | locate-workspace |
ensure_dirs(&ctx.workspace) materialises the profile directory layout on disk (a failed mkdir is non-fatal, reported and continued). |
| 2 | apply-upgrades |
Fold the idempotent apply_upgrades registry over the workspace — fold-credentials-into-secure-auth-file, reshelve-loose-transcripts-into-sessions-dir, relocate-managed-helper-binaries-to-bin, rename-legacy-commands-dir-to-prompts, each marker-gated and retried next launch on failure. |
| 3 | build-invocation |
read_invocation(&ctx.argv) then map_invocation projects the rich launch Invocation onto the boot-routing Invocation the registry keys off. |
| 4 | resolve-resources |
resolve_startup_resources(&ctx) best-effort assembles the settings/auth/model graph (degrades to None on any framework gap). |
| 5 | select-runner |
A marker/tracing no-op — the real dispatch happens in boot_run after the pipeline so the runner owns the exit code. |
map_invocation is where the launch OutputMode becomes the boot RunnerId: Rpc → Link, Json → Oneshot, Text → Repl. It also threads --resume/--continue/--model/--cwd/--account/--thinking/--system/--append-system/--tools/--no-tools/--mcp and the --list-models filter onto the routing shape.
A finally-equivalent — the unconditional drain_closables(&ctx) — runs every teardown callback latest-registered first, swallowing individual failures so one bad closer cannot mask the exit code or strand the rest. BootContext::closables is a Mutex<Vec<Closable>> (interior mutability) so a runner holding a shared &BootContext can still register teardowns.
Runner Dispatch
The pipeline hands the resolved Invocation to exactly one Runner. Dispatch is a table lookup, not an if/else ladder: registry() lists every runner in priority order and RunnerRegistry::select returns the first whose accepts predicate matches inv.mode.
// src/boot/runners.rs
pub fn registry() -> RunnerRegistry {
RunnerRegistry::new(vec![
Box::new(ReplRunner),
Box::new(OneShotRunner),
Box::new(LinkRunner),
])
}
The registry is total: unlike the framework's fallible indusagi::shell_app::select_runner (which returns Result<_, NoRunnerError> for output-only help/version modes), the agent's select falls back to the first runner (ReplRunner) so a bare command line always lands interactive — correct because boot_run never sees help/version/list-models (those exit in bin/main.rs).
| Runner | id() |
Behaviour |
|---|---|---|
ReplRunner |
RunnerId::Repl |
Builds the session conductor, honours --resume / --continue via apply_resume, then mount_console(conductor, opts) behind the tui feature for the live ratatui session. On a --no-default-features headless build it reports that the interactive surface needs the tui build and exits non-zero. |
OneShotRunner |
RunnerId::Oneshot |
One non-interactive run to stdout via run_oneshot over a ChannelContext — clean text, or NDJSON when the shape is set; no request text exits EXIT_NO_INPUT (2). See Channels. |
LinkRunner |
RunnerId::Link |
Reads framed JSON-RPC request lines from the boot InputSource, dispatches each through the declarative session_ops registry via dispatch, and frames one correlated Reply back through NDJSON encode_line. EOF exits 0; blank/malformed lines tolerated. |
Each runner's accepts keys solely off inv.mode, and the trait's run is async (?Send) — it borrows the (intentionally non-Sync) BootContext and resolves the exit code by value, never calling process::exit, so boot_run stays in control of teardown.
How a Session Is Assembled
All three runners call the shared build_conductor(ctx) in boot/runners.rs — the Rust analogue of the TS buildSessionConductor. It threads the invocation's flags into one framework agent loop through the Conductor:
indusr --model anthropic/claude-sonnet-4-5 --thinking high \
--tools read,write,bash --append-system ./extra-prompt.md \
--mcp ./mcp.json "refactor this module"
- Model —
resolve_model_id(inv.model_id, saved_default_model(ctx), authenticated_providers(...))resolves explicit--model▸ savedsettings.default_model▸ an authenticated provider's current model ▸ the catalog fallback, then seedsAgentConfig::new(model_id). The thinking level threads throughresolve_thinking(the TS aliasesminimal/xhighfold onto frameworkLow/Max). - Tools —
build_session_tools(inv, cwd)callsprovision_deck(Profile::All, ctx)from the capability deck, honours the--toolsallow-list (case-insensitive,_/--stripped viacanon_tool_name) and--no-tools, thengrafts in the--mcp(or auto-discovered) server tools and the local<cwd>/.indus/addonscontributed tools + interceptor chain. Per-session read-state and checkpoint stores are minted ontoDeckFrameworkso write/edit gate on read-before-edit and snapshot for rewind. - System prompt —
compose_session_briefing(inv, cwd, descriptors)builds the tool-aware briefing;--systemreplaces it,--append-systemappends a trailing block. - Permissions —
resolve_permission_policy(ctx, cwd)reads the project-then-globalPreferenceStoretiers into ordered deny/ask/allowPermissionRules + an openingPermissionMode; when there is anything to enforce (rules, a non-allow-all mode, or plan-mode reachable in the REPL), the deck is wrapped in aGatedToolBoxand re-seated. - Credentials —
build_key_resolver(ctx)builds an--account-scopedKeyResolverover the on-disk vault (requested account ▸ default ▸ first), stamping the resolved key onto each request'sStreamOptions::api_key— nounsafeenv mutation (#![forbid(unsafe_code)]). - The conductor —
Conductor::new(config, LoopDeps { permission_mode, store: SessionStore::new(sessions_dir), key_resolver, fallback_model_id, plans_dir, workspace, diagnostics, .. })wraps exactly one framework agent loop. The cwd-scopedsessions_dir(session_scope_dir_for) is the SAME directory the resume flow reads and the overlays list, so writer and reader agree on the.jsonltranscript tree.
The runner then mounts the console over the conductor (ReplRunner) or drives it through a channel (OneShotRunner / LinkRunner).
Module Map
Each subsystem is a pub mod in lib.rs, reached as induscode::<module>. The framework is reached as indusagi::<module>. The table groups the thirteen modules by concern.
Launch & lifecycle
| Module | Responsibility |
|---|---|
launch |
The CLI front door: the single FLAG_SPECS table + read_invocation parser + render_usage, the @file gather_attachments inliner, the multi-account DiskAuthVault + run_credential_command, the three OAuth flows (Anthropic / OpenAI Codex / GitHub Copilot), the ModelRegistry catalog over ModelCard, and the picker data. |
boot |
The launch orchestrator: boot_run, the five-stage BootPipeline, the idempotent apply_upgrades registry, the total RunnerRegistry (registry/select), the build_conductor session factory, resolve_startup_resources, addon wiring, and drain_closables. |
core |
The framework-agnostic leaf: the BRAND record (app_name indusagi, bins ["indus", "indusagi"], env prefix INDUSAGI, profile dir .indusagi, env_profile_dir INDUSAGI_CODING_AGENT_DIR), VERSION (compile-time), the pure create_workspace/ensure_dirs locator, the two-tier PreferenceStore, the SessionLibrary, the boot value contracts, and stdlib kit helpers. |
The session core
| Module | Responsibility |
|---|---|
conductor |
The Conductor over one framework agent loop. Owns the product SessionSignal stream, the persistent branchable transcript store, the model catalog/matcher, the permission gate (create_permission_gate, GatedToolBox), PermissionMode cycling, the post-edit DiagnosticsEngine, plan-mode, and submit/snapshot/resume. |
deck |
The tooling layer: the Capability catalog, the provision_deck(Profile, DeckContext) assembler, the app-novel cards app_novel_cards() appends after the framework built-ins (todo / bg-process / task / saas / memory / the two plan-mode markers enter_plan_mode+exit_plan_mode), the CheckpointingCard write/edit wrapper, DeckFramework (read-state + checkpoint stores), and the event-sourced MCP bridge ledger (load_mcp_config, attach_bridge_capabilities). |
window_budget |
Context-window budgeting: token measurement, slice planning, and the conductor-consumable transcript condense engine that feeds /compact and auto-compaction. |
briefing |
The declarative system-prompt pipeline (compose_briefing_with_options over BriefingInput/ComposeOptions/ToolDoc), the macro model, and the Agent-Skills card loader. |
Surfaces & I/O
| Module | Responsibility |
|---|---|
console |
The interactive ratatui shell: the pure console reducer, the theme engine, the data-driven slash-command catalog, the input/intent/completion modules, the overlay dialogs (/model, /resume, /login, /settings, …), and mount_console(conductor, MountConsoleOptions). |
channels |
The non-interactive channels: the ChannelContext/LineSink/OneshotRequest/OneshotShape carrier, run_oneshot, the declarative session_ops JSON-RPC registry + dispatch, the NDJSON encode_line framer, and the inert dialog. |
transcript_export |
The HTML transcript publisher: the SGR-to-HTML painter, the WCAG-luminance theme bridge (pulldown-cmark + syntect), the page-shell template, and publish_transcript. |
Integrations & extension
| Module | Responsibility |
|---|---|
runtime_bridge |
Provider routing for external runtimes: the bridge:<adapter> endpoint convention, the provider-neutral normalized event union, the child-transport boundary, and the broker/route surface. |
addons |
The third-party customization contract: the addon manifest/surface, the colon-named hook taxonomy + dispatcher, the ToolInterceptor chain, addon-contributed tools (AddonCommand) and commands, and the module loader (wired into the deck by boot::addon_wiring). |
insight |
The observability plane: a thin wrapper over framework tracing plus an in-memory collector sink and NDJSON replay readers. |
The repl runner mounts the console behind the tui feature (on by default — default = ["rustls", "mcp", "tui", "oauth"]). The console and addons modules always compile (they are plain pub mods in lib.rs with no #[cfg] gate); the tui feature does not strip them — it only forwards to the framework indusagi/tui render layer. What the feature gates is the mount_repl body: with tui it hands the conductor to mount_console's live ratatui loop, and on a --no-default-features build it short-circuits to EXIT_REPL_UNAVAILABLE (1), so interactive runs need the tui build while one-shot/link still work. Three optional off-by-default features tune the build: mimalloc (global allocator), swarm (the boot delegate-runner stage bridging addon subagent profiles onto the deck delegate handle), and composio (the SaaS-action backend behind the saas card).
Where the Framework Begins
induscode is built on top of the indusagi framework and links its published types rather than re-deriving them. The boundary is sharp and deliberate:
- The conductor drives one framework agent loop (
indusagi::runtime) and consumes its event stream internally; it re-emits a distinct productSessionSignalstream and never re-declares the framework's message/usage shapes. It also threadsindusagi::runtime::store::SessionStorefor persistence. - The deck projects
Capabilityinto the frameworkindusagi::runtime::ToolBoxfrom capabilities, so contributed tools feed the agent directly. MCP mounting goes through the framework client pool. - The console renders over the framework's tui primitives — induscode contributes only the reducer, intent routing, autocomplete, overlay flows, and
mount_console. - The launch layer builds on the framework gateway's PKCE/transport credential primitives (
indusagi::llmgateway::{build_auth_url, exchange_code, refresh_token, create_pkce_pair, …}) andindusagi::shell_app::auth_cli::{run_auth_command, CredentialStore}; the model catalog readsindusagi::llmgatewayModelCards. - The boot pipeline ports the framework template
indusagi::shell_app::boot::{Stage, run_stages, BootIo}andindusagi::shell_app::runners::{Runner, select_runner}. - State lives under
~/.indusagi/(resolved bycore::create_workspace, honouringINDUSAGI_CODING_AGENT_DIR), sharing the framework's flat profile-dir convention.
For the framework's own internal architecture — the LLM gateway, runtime, capabilities, and shell-app layers induscode sits above — see the framework overview. For the other editions of the same agent, see the Python CLI and the TypeScript CLI.
