Boot
The launch orchestrator of the Rust agent:
boot_run(argv, io)seeds an immutableBootContext, folds it through the five-stageBootPipeline(locate-workspace → apply-upgrades → build-invocation → resolve-resources → select-runner), dispatches the resolvedRunnerthrough the totalRunnerRegistry(repl is the fallback), and drains closables in reverse on the way out. Everything that wires the other subsystems together at startup — runner selection, the shared conductor factory, per-session safety stores, the disk auth vault, idempotent profile upgrades, and addon wiring — lives underinduscode::boot.
Table of Contents
- Overview
- The boot orchestrator
- The orchestrator contract
- The stage pipeline
- Invocation projection
- Runner dispatch
- The three runners
- The shared conductor factory
- Resource assembly
- The credential vault
- Addon wiring
- The delegate runner
- Profile upgrades
- Feature flags
- Public exports
- Examples
- Key concepts
Overview
induscode::boot is the top-of-stack assembly layer of the Rust agent:
it turns a sliced argv plus an I/O seam into a running coding agent and a process
exit code. It is the merged-crate analogue of the TS boot and Python
boot subsystems, ported over the framework template
indusagi::shell_app::boot (Stage, run_stages, BootIo, OutputSink,
InputSource, Closable) and indusagi::shell_app::runners (Runner,
select_runner).
The design stance, matching the other editions, is pipeline as data, not control
flow: stages are Box<dyn Stage> rows folded by BootPipeline::run, and dispatch
is a first-accept scan over an ordered RunnerRegistry rather than an if/else
ladder. Adding a stage or a mode is a one-line edit to a table.
The module is also the single import site for the orchestrator contract. The crate's
boot/mod.rs re-exports the contract types so callers code against one surface:
pub use contract::{
BootContext, BootIo, BootPipeline, Closable, InputSource, Invocation, OutputSink,
Runner, RunnerId, RunnerRegistry, Stage, StartupResources,
};
Boot consumes the launch flag grammar
(launch::parser::read_invocation), the conductor
for the session engine, the capability deck
for tools, briefing for the system prompt,
channels for the non-interactive transports, and the
console only behind the tui feature so a headless
build never pays the ratatui cost. Against the framework it types its resolved-resource
graph against indusagi::shell_app::Settings and drives the agent loop through
indusagi::runtime.
Two layers of contract exist, deliberately:
induscode::core::boot_contract(milestone M0) owns the value shapes —BootContext,Invocation,RunnerId,StartupResources,CredentialGraph,ModelCatalog, plus syncStage/Runnertrait skeletons. It lives in the leafcoremodule becauselaunch/deck/settingsimport these shapes, and putting them inbootwould create aboot ⇄ launchcycle Cargo forbids.induscode::boot::contractre-states the async orchestrator form over the same value types — the asyncStage/Runnertraits, the line-orientedBootIoseam, and a future-returningClosable— because the frameworkrun_stagesfold and the runner turn loop areasync.
The boot orchestrator
boot_run(argv, io) is the single async function the binary entry (src/bin/main.rs)
calls. Its signature is stable:
pub async fn boot_run(argv: Vec<String>, io: BootIo) -> std::process::ExitCode;
The contract is explicit: boot_run never calls process::exit — it maps every
outcome to an ExitCode returned by value (the only place a code reaches the OS,
mirroring the framework cli.rs discipline) — and it always drains the context's
closables in reverse on the way out, regardless of how the runner resolved.
The body is short and linear:
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;
let registry = runners::registry();
let code = registry.select(&ctx.invocation).run(&ctx).await;
drain_closables(&ctx).await;
ExitCode::from(code as u8)
}
- Seed.
seed_context(argv, io)builds the initialBootContext: it resolves theWorkspaceviacore::create_workspace(Default::default())(pure path computation — directories are materialised later), setsBRAND, seeds an emptyclosableslist and a placeholderInvocation::default()(modeNone), and threads theBootIoseam through. - Fold.
pipeline::stages()builds the five-stageBootPipeline;pipeline.runfolds it over the seed. - Dispatch.
runners::registry()builds the[repl, oneshot, link]registry;registry.select(&ctx.invocation)returns the first runner whoseacceptsmatchesmode(repl is the total fallback), andrun(&ctx)drives it to ani32exit code. - Drain.
drain_closables(&ctx)runs every teardown, latest-registered first.
Notably, the credential / package verbs (signin, signout, auth …, install,
remove, update, list, config) and the meta short-circuits
(--help / --version / --list-models) are owned by the route() function in
src/bin/main.rs, which renders usage and routes the auth subcommand through
launch before calling boot_run. So by the time
argv reaches boot_run it is always a session-bound launch — the pipeline parses it and
the registry dispatches the resolved runner. This keeps boot_run the single session
entry while the binary owns the no-session verbs.
Closable draining
pub async fn drain_closables(ctx: &BootContext) {
let drained: Vec<Closable> = match ctx.closables.lock() {
Ok(mut guard) => guard.drain(..).collect(),
Err(poisoned) => poisoned.into_inner().drain(..).collect(),
};
for close in drained.into_iter().rev() {
close().await;
}
}
The list is drained under the Mutex (recovering from a poisoned lock), then each
teardown is awaited in reverse registration order. Individual failures are swallowed
so one bad closer cannot mask the run's exit code or strand the rest.
The orchestrator contract
boot/contract.rs declares the async seams every other boot module is written against,
re-exporting the value shapes from core::boot_contract so nothing is re-declared.
| Name | Kind | Purpose |
|---|---|---|
BootContext |
struct | The value threaded through the pipeline: argv, workspace, brand, invocation, resources: Option<StartupResources>, the live io: BootIo, and closables: Mutex<Vec<Closable>> |
Invocation |
struct (from core) |
The thin routing projection of the parsed command line (see below) |
RunnerId |
enum (from core) |
Repl / Oneshot / Link — the three top-level execution modes |
StartupResources |
struct (from core) |
Resolved settings (indusagi::shell_app::Settings) + auth: CredentialGraph + models: ModelCatalog |
Stage |
#[async_trait] trait |
fn name(&self) -> &str + async fn apply(&self, ctx: BootContext) -> BootContext |
BootPipeline |
struct | Vec<Box<dyn Stage>> + run(&self, initial) -> BootContext |
Runner |
#[async_trait(?Send)] trait |
fn id() -> RunnerId, fn accepts(&Invocation) -> bool, async fn run(&self, &BootContext) -> i32 |
RunnerRegistry |
struct | The ordered runner table + the total select first-accept scan |
BootIo |
struct | output: Arc<dyn OutputSink> + input: Arc<dyn InputSource> |
OutputSink |
trait | write(&str) + write_error(&str) — line-oriented user/diagnostic output |
InputSource |
#[async_trait] trait |
async fn next_line(&self) -> Option<String> — None at EOF |
Closable |
type alias | Box<dyn FnOnce() -> Pin<Box<dyn Future<Output = ()> + Send>> + Send> |
BootContext::closables uses interior mutability (Mutex<Vec<Closable>>) because
runners hold a shared &BootContext through Runner::run yet still need to register
teardowns — the faithful analogue of the TS Array.push onto a mutable field of an
otherwise read-only object. BootContext exposes push_closable(closable) and a
convenience output_error(text).
Each Stage::apply returns a fresh successor via struct-update (BootContext { field, ..ctx }), never an in-place mutation. The framework Runner::run is ?Send because
the future borrows the (intentionally non-Sync) BootContext and the launcher drives
it inline on one task, so a Send bound would buy nothing and force Sync onto every
captured boot resource.
The registry differs from the framework's fallible indusagi::shell_app::select_runner
(which returns a Result because the shell's help/version are output-only modes): the
agent's RunnerRegistry::select is total — repl is the first entry and the
guaranteed fallback — which is correct precisely because boot_run only ever sees
session-bound launches.
pub fn select(&self, inv: &Invocation) -> &dyn Runner {
self.runners
.iter()
.find(|r| r.accepts(inv))
.map(|r| r.as_ref())
.unwrap_or_else(|| self.runners.first().map(|r| r.as_ref()).expect("repl fallback"))
}
The stage pipeline
boot/pipeline.rs builds the ordered list and folds it. stages() assembles the five
Box<dyn Stage> rows; BootPipeline::run reduces a seed through each, awaiting strictly
in order (never concurrently). The input is never mutated.
pub fn stages() -> BootPipeline {
BootPipeline::new(vec![
Box::new(LocateWorkspace),
Box::new(ApplyUpgrades),
Box::new(BuildInvocation),
Box::new(ResolveResources),
Box::new(SelectRunnerMarker),
])
}
| Stage | name() |
Effect |
|---|---|---|
LocateWorkspace |
locate-workspace |
ensure_dirs(&ctx.workspace) — mkdir -ps the resolved layout (path computation already happened in the seed). Best-effort: a mkdir failure is reported through output_error but never aborts boot |
ApplyUpgrades |
apply-upgrades |
Folds the idempotent apply_upgrades(&ctx.workspace) registry; each warning is printed, never fatal |
BuildInvocation |
build-invocation |
Re-parses argv via launch::parser::read_invocation, then projects the launch Invocation down onto the boot-routing Invocation via map_invocation |
ResolveResources |
resolve-resources |
Attaches the best-effort StartupResources graph from resources::resolve_startup_resources(&ctx) onto ctx.resources |
SelectRunnerMarker |
select-runner |
A marker / tracing no-op; the real dispatch happens in boot_run after the pipeline so the chosen runner owns the exit code |
The LocateWorkspace and ApplyUpgrades stages return the context unchanged (they
have only filesystem side-effects); BuildInvocation and ResolveResources return a
struct-update successor with one field set.
Note the deliberate double-parse: in the other editions a seed parse runs the meta
short-circuits up front, but in the Rust edition the no-session verbs and meta flags are
intercepted in bin/main.rs before boot_run. The pipeline's build-invocation
stage is therefore the single parse that drives session dispatch, and it shares the one
launch parser with bin/main.rs so help, parsing, and routing can never drift.
Invocation projection
build-invocation parses the full launch flag grammar (read_invocation) and projects
the rich launch Invocation down onto the thin boot-routing Invocation the registry
keys off. The launch parser owns the grammar (see Launch);
the boot layer only needs the routing subset.
map_invocation maps the launch OutputMode onto RunnerId one-to-one:
OutputMode |
flag | RunnerId |
Meaning |
|---|---|---|---|
Rpc |
--json / --rpc |
Link |
Headless JSON-RPC link for a driving parent |
Json |
--print && !--interactive |
Oneshot |
Single non-interactive request to stdout |
Text |
(default) | Repl |
Interactive terminal session |
let mode = match parsed.mode {
OutputMode::Rpc => RunnerId::Link,
OutputMode::Json => RunnerId::Oneshot,
OutputMode::Text => RunnerId::Repl,
};
The projection threads the routing-relevant fields onto the boot Invocation: prompt,
model_id, fallback_model_id, cwd, account, thinking (as a string), system,
append_system, tools (allow-list), no_tools, mcp, resume, continue_latest,
list_models (+ list_models_filter), and rest (positionals).
The --list-models value lands in the loose flags bag keyed "list-models": presence
sets list_models = true and a non-empty string becomes list_models_filter. The
boolean --resume (-r) and --continue (-c) are read out of the same bag —
flag_set("resume") / flag_set("continue") — so the repl runner's resume flow actually
fires (an earlier audit had hardcoded both false).
Runner dispatch
boot/runners.rs builds the registry and holds the three runners. registry() is the
table-driven replacement for a mode if/else ladder:
pub fn registry() -> RunnerRegistry {
RunnerRegistry::new(vec![
Box::new(ReplRunner),
Box::new(OneShotRunner),
Box::new(LinkRunner),
])
}
The order is [repl, oneshot, link] and selection is the first-accept scan from
RunnerRegistry::select, falling back to the first entry (ReplRunner) so dispatch is
total. Each runner's accepts keys solely off inv.mode; each run first builds the
shared conductor and then drives its surface to an exit code. The exit codes used:
| Const | Value | Used by |
|---|---|---|
EXIT_OK |
0 |
Link stream EOF; clean repl mount teardown |
EXIT_REPL_UNAVAILABLE |
1 |
A headless build asked to mount the interactive console; a terminal lifecycle failure |
EXIT_NO_INPUT |
2 |
A one-shot run launched with no request text |
The three runners
`ReplRunner` — interactive ratatui console (the default + total fallback)
Matches mode == Some(RunnerId::Repl). Its run builds the session conductor via
build_conductor(ctx), honours --resume / --continue via apply_resume before
mounting (so the console renders the restored transcript from its first frame), then
calls mount_repl(ctx, conductor).
This is the inversion of how the TS repl-runner mounted Ink: there mountConsole
owned the React loop; here the runner hands the conductor to
console::mount_console, which owns the blocking
immediate-mode ratatui loop and returns on exit.
mount_repl is feature-gated. Behind tui it assembles MountConsoleOptions and calls
mount_console(conductor, opts).await, seeding:
initial_inputfrom the positional prompt (blank/absent leaves the composer empty);cwdfrom the resolved--cwd(else process cwd);sessions_dirfromsession_scope_dir_for(inv, workspace)— the same cwd-scoped directorybuild_conductorrooted its persistence store at, so the/resume,/tree,/branch, and/timelineoverlays list exactly what the live session writes;auth_path(theauth.jsonthe/login//logoutoverlays read/write — the same store the headlesssignin/signoutverbs use);prefs(the two-tierPreferenceStorethe/settingsand theme overlays read/commit).
A mount error (terminal lifecycle failure) is reported once and mapped to
EXIT_REPL_UNAVAILABLE. On a --no-default-features (headless) build the tui-gated
body compiles out and a bare/interactive command line is told the interactive surface
needs the tui build — it never mounts a plain-text fallback (the TS repl-runner has
none either).
Resume
apply_resume(ctx, &conductor) is a no-op unless --resume or --continue is set. It
opens a SessionLibrary over the cwd-scoped directory, picks the target session id via
choose_resume_target, and calls conductor.resume(session_id). Both flags resolve to
the most recent row from the newest-first SessionLibrary::list (the head row is the
most recent session in the cwd). After resume it checks for a ConductorPhase::Faulted
snapshot and, on failure (no rows, load fault), degrades to the fresh session with a
one-line stderr notice rather than crashing.
`OneShotRunner` — the `--print` path
Matches mode == Some(RunnerId::Oneshot). It computes oneshot_prompts(&inv) (the
positional first prompt when present); with no request text it writes a notice and exits
EXIT_NO_INPUT (2) rather than opening a wasted model round. Otherwise it builds the
conductor, wraps it in a ChannelContext over a stdout LineSink (StdoutSink) and the
inert_dialog, and calls channels::protocol::run_oneshot(&channel, &request). The
output shape is OneshotShape::Text (NDJSON support is wired through the launch
projection downstream). It mirrors the framework OneShotRunner — driving the
conductor, not a bare agent, through the channel's snapshot-fallback text strategy so
--print is never silently empty. See Channels.
`LinkRunner` — the headless JSON-RPC wire
Matches mode == Some(RunnerId::Link). It builds the conductor, the
channels::protocol::session_ops() registry, and a ChannelContext over StdoutSink,
then runs a serialized read loop over the boot InputSource seam: each framed request
line is handled to settlement before the next is read, blank lines are ignored, and EOF
(None) ends the loop with EXIT_OK. handle_link_line parses the JSON-RPC envelope,
dispatches the method through channels::protocol::dispatch, and frames exactly one
correlated Reply back through the NDJSON channels::framer::encode_line — a request
with no id is a notification (dispatched for effect, never replied to); a malformed
line yields a single framed parse error and the loop continues. It mirrors the framework
WireRunner read-loop discipline but is data-driven through the declarative op registry.
The shared conductor factory
build_conductor(ctx) is the shared factory both non-interactive runners and the repl
runner call — the Rust analogue of the TS buildSessionConductor. It assembles a
fully-wired session Conductor so the agent it drives
has its deck tools callable and its system prompt composed — exactly what -p and the
interactive console need.
The steps:
- Model resolution —
resolve_model_id(inv.model_id, saved_default, &authed)runs the full precedence: an explicit--modelwins; else the savedsettings.default_modelfrom the two-tierPreferenceStore(an empty string counts as unset, so a fresh install falls through); else the first authenticated provider's current model (authenticated_providers(&auth_path)); else the catalog fallback. Reading the saved model and the authenticated providers here is what lets a persisted/modelchoice bind on every startup with no picker. - Thinking level —
resolve_thinking(inv.thinking)maps the token onto anindusagi::llmgateway::contract::ThinkingLevel(off/low/medium/high/max; the aliasesminimal→Lowandxhigh→Maxfold onto the nearest real level). - Tools + MCP + addons —
build_session_tools(inv, cwd)provisions the deck's 12 built-in cards (read / write / edit / bash / grep / find / …) plus the app-novel cards, grafts the configured MCP servers, then layers addons (see Resource assembly and Addon wiring). - System prompt —
compose_session_briefing(inv, cwd, &descriptors)builds the tool-aware briefing;--systemreplaces it,--append-systemappends. - Permission policy —
resolve_permission_policy(ctx, cwd)reads the project-then-globalpermissionsblock from thePreferenceStoreand pushesdeny, thenask, thenallowrules (so a deny anywhere wins the scan) and takes the firstdefault_mode. Plan mode is reachable only in the repl (plan_reachable = inv.mode == Some(RunnerId::Repl)). - Session scoping + persistence —
session_scope_dir_for(inv, workspace)partitions sessions per working directory; aindusagi::runtime::store::SessionStorerooted there is threaded ontoLoopDeps::storeso each settled turn is durably appended to the cwd-scoped.jsonlfile. The same directory isplans_dirfor approved plan-mode plans. - Diagnostics — when the cwd carries a
tsconfig.json, aDiagnosticsEngineis wired so a turn that edits TS/JS gets its new type errors injected into the next turn. - Key resolver —
build_key_resolver(ctx)(see below) is threaded ontoLoopDeps::key_resolver;--fallback-modelontoLoopDeps::fallback_model_id.
The conductor is built first so the gate can read its live mode holder and approval
delegate, then the deck is wrapped in a GatedToolBox and re-seated via
conductor.register_tools(Some(gated)). The gate is omitted only when there is nothing
to enforce — no rules AND an allow-all mode (Default/Bypass) AND plan not reachable —
to keep an install with no permissions block at allow-all behaviour.
build_key_resolver returns an account-scoped api-key resolver over the on-disk
AuthVault, or None when there is nothing to scope (no --account and no vault file).
The closure maps a provider slug to its usable key by trying, in order, the requested
--account, the provider's flagged default account, then its first stored account. The
conductor mints agents whose model invoker stamps the resolved key onto each request's
StreamOptions::api_key — the credential override seam — so a vault key reaches the wire
without std::env::set_var (this crate is #![forbid(unsafe_code)]). Returning None
lets the gateway fall back to its own environment lookup; it never panics.
Resource assembly
boot/resources.rs owns the startup-resource graph plus the per-session safety stores,
the memory store, the session-directory helpers, and the auth vault.
Startup resources
build_startup_resources() returns a StartupResources from the framework baseline:
pub fn build_startup_resources() -> StartupResources {
StartupResources {
settings: indusagi::shell_app::DEFAULT_SETTINGS(),
auth: CredentialGraph::default(),
models: ModelCatalog { resolved: true },
}
}
resolve_startup_resources(&ctx) (the resolve-resources stage's delegate) returns
Some(build_startup_resources()). Because the framework is a compile-time dependency in
Rust, the TS dynamic-import / try-catch degrade dance collapses — but the
degrade-to-minimal shape survives via the StartupResourcesExt::minimal() trait
(framework Settings::default(), empty auth, an unresolved catalog marker) so a future
fallible disk-merged settings load still has somewhere to land.
Session scoping
session_scope_dir(sessions_root, cwd) is the canonical cwd-scoped directory both the
conductor (writer) and the SessionLibrary (reader) agree on:
pub fn session_scope_dir(sessions_root: &Path, cwd: &str) -> PathBuf {
sessions_root.join(format!("--{}--", slug_cwd(cwd)))
}
slug_cwd collapses every non-alphanumeric run to one dash and trims leading/trailing
dashes, so /Users/me/proj → --Users-me-proj--. The same slug scheme drives
memory_dir_for(profile_dir, cwd) → <profile_dir>/memory/--<slug>--, kept under the
profile dir so persisted memory never pollutes the working tree.
Per-session safety stores
| Store | Key constant | Trait projected onto | Role |
|---|---|---|---|
ReadStateStore |
READ_STATE_HANDLE_KEY = "readState" |
indusagi::capabilities::ReadStateHandle |
The read-before-edit gate: records a ReadStateRecord (mtime/size/hash/read_at) per file read so write/edit refuse to mutate a file the session never read or that drifted on disk |
CheckpointStore |
CHECKPOINT_HANDLE_KEY = "checkpoint" |
deck::contract::CheckpointStore |
The rewind store: per transcript node id, first-seen pre-mutation file content; record captures, restore(node_id) rolls the tree back (writing old content, or deleting files that were absent) |
DiskMemoryStore |
MEMORY_HANDLE_KEY = "memoryStore", MEMORY_ENTRYPOINT = "MEMORY.md" |
(used by the memory card) |
Durable per-cwd MEMORY.md (read/replace/append), written owner-only (0o600) on unix; load_memory_doc inlines it into the briefing capped at MAX_ENTRYPOINT_LINES (200) / MAX_ENTRYPOINT_BYTES (25 000) |
Both gate stores key on a normalised absolute path (normalize_path collapses . and
.. lexically, the way Node's path.normalize does) so two spellings of the same file
land on one entry. create_read_state_store() and create_checkpoint_store() mint fresh,
independent per-session instances threaded onto DeckFramework.
register_closable(ctx, closable) is the thin push-side convenience over
BootContext::push_closable for stages/runners that open a long-lived resource (an MCP
fleet, a link server) so drain_closables runs the teardown in reverse even on error.
The credential vault
boot/resources.rs also implements AuthVault — the disk-backed multi-account
credential store keyed provider → account → record, persisted to a single auth.json
under the profile dir. create_auth_vault(path) is the free-fn constructor.
Each record is a discriminated AuthRecord:
pub enum AuthRecord {
ApiKey { key: String, is_default: bool },
OAuth { access: String, refresh: Option<String>, expires: Option<u64>, is_default: bool },
}
The on-disk JSON shape is { "kind": "apiKey"|"oauth", … }; a legacy pre-discriminant
{ "apiKey": …, "isDefault": … } record is tolerated on read, and an entry that cannot
be understood is dropped (normalise_record). The vault reads and rewrites the whole
file each call (the file is tiny and operations are interactive), re-applying 0o600
(owner-only, unix) on every write. The public surface:
| Method | Purpose |
|---|---|
list_accounts(provider) |
The account names stored for a provider |
default_account(provider) |
The provider's flagged default account, if any |
put_api_key(provider, account, key, make_default) |
Store an api key (clearing other defaults when make_default) |
put_oauth(provider, account, access, refresh, expires, make_default) |
Store a browser-sign-in bundle |
auth_kind(provider, account) |
The "apiKey" / "oauth" discriminant |
read_usable_key(provider, account) |
Resolve a record to a live api-key string |
remove(provider, account) |
Remove a whole provider (None) or one account, promoting a survivor to default |
read_usable_key returns an api-key record's key verbatim, or a browser-sign-in record's
access token (the value the framework's own lookup reads). Fidelity note: the
frozen Rust framework publishes no OAuth-refresh symbol, so live token rotation is
deferred — the stored access token is returned unrefreshed (the TS/Python editions
refresh first when the recorded expires is near). See
Auth for the credential model and the parity with the
launch-side DiskAuthVault.
Addon wiring
boot/addon_wiring.rs activates the addon host for a
session — the product's extension mechanism for locally-authored modules under
<cwd>/.indus/addons. build_session_tools calls it once at deck-assembly time, after
the built-in deck and the MCP graft, so addon tools and interceptors layer on top.
build_addon_wiring(cwd) returns an AddonWiring:
pub struct AddonWiring {
pub interceptors: Arc<InterceptorChain>,
pub tools: Vec<Capability>,
pub commands: Vec<AddonCommand>,
}
It builds an AddonHost over the declarative TOML loader (DeclarativeLoader — data,
not code), installs a swallow-everything fault sink (host.on_fault(...)) so a broken
addon degrades silently rather than crashing boot, and load_alls every addon discovered
under the .indus/addons directory. With no such directory the bundle is empty and the
wiring is a perfect no-op — the deck flows through unchanged.
The runner threads each piece into the right place:
concat_addon_tools(deck, addon_tools)appends contributed tools, dropping any whose name a deck/MCP tool already claims (first-claimant-wins de-dup, so an addonreadcannot shadow the core read tool). ContributedTools are adapted to deckCapabilitys viaBuiltinCardand treated as mutating (read_only = false), so the permission gate prompts rather than auto-allowing an unknown tool.wrap_one_with_addons(cap, &chain)(folded over every capability by the deck'smap_capabilitieswalk) wraps a matching capability in anInterceptedCardthat runs the chain's enter→execute→exit reduce around the realrun; a blocked enter short-circuits to anis_errorresult and the real tool never runs. A non-matching or empty chain returns the sameArc, so an addon-less session is identity-equal.
Scope (v1, matching the other editions): only the per-tool interceptor boundary and tool
contribution are wired here. The richer lifecycle-event fan-out (session:start,
turn:end, …) needs a conductor seam that does not exist yet and is deferred. The
bundle's slash commands are returned for the console command registry.
The delegate runner
boot/delegate.rs (feature swarm) is the boot stage that bridges
addons subagent profiles onto the deck's task card. It
discovers .indus/agents/<name>.toml subagent profiles
(addons::subagents::discover_profiles) and adapts them onto the framework swarm
coordinator (addons::subagents::DelegateRunner), exposing the result as the deck's
deck::contract::DelegateRunner trait object via SwarmDelegateAdapter.
build_delegate_runner(workspace, crew_root) returns Some(Arc<dyn DelegateRunner>),
or None when no profiles are discovered (the deck task card then keeps its typed-stub
behaviour). On a delegation the adapter builds a per-run crew directory under crew_root
(a unique suffix keeps concurrent delegations on independent boards), posts one
DelegationTask built from the request objective (with context folded into the body),
drives the swarm rounds, and relays the framework's settled report back as the deck's
opaque result. The adapter owns no coordination logic — every guarantee (the JSONL board,
dep-gated readiness, the mailbox, git-worktree isolation) is the framework swarm crate's.
A swarm fault is a failed delegation, never a panic. Behind the swarm feature so a
headless/minimal build drops indusagi-swarm entirely.
Profile upgrades
boot/pipeline.rs also holds the idempotent profile-upgrade subsystem the
apply-upgrades stage folds. apply_upgrades(ws) folds the ordered UPGRADES registry
over the workspace using a .upgrade-state.json marker file
({"appliedIds": [...]}) under the profile directory, returning an UpgradeReport { applied, warnings }.
Each step is skipped if its id is already in the marker; otherwise it is applied and, on success, its id is appended and the marker rewritten after each success (so partial progress survives a crash). A step that fails is recorded as a non-fatal warning, left unmarked (retried next launch), and never blocks the remaining steps. A missing or malformed marker degrades to "nothing applied yet" so a corrupted marker re-attempts (idempotent) steps rather than crashing.
The four registered migrations:
| Id | Effect |
|---|---|
fold-credentials-into-secure-auth-file |
Consolidate a legacy oauth.json + the apiKeys block in settings.json into a 0o600 auth.json; retire the consumed oauth.json to oauth.json.retired |
reshelve-loose-transcripts-into-sessions-dir |
Move loose *.jsonl transcripts (recognised by a type: "session" first-line header) under the profile root into sessions/<projectDirName>/ |
relocate-managed-helper-binaries-to-bin |
Move managed fd/rg helper binaries from the legacy tools/ dir into bin/ |
rename-legacy-commands-dir-to-prompts |
Rename the legacy commands/ template directory to prompts/ |
Each step is idempotent and skip-on-clobber (relocate_without_clobber never overwrites a
live destination). On the fresh ~/.indusagi-style profile root these are documented
no-ops — that root never carried a legacy layout — but their ids stay reserved and the
mechanism stays fully exercisable. Append real steps to the end of UPGRADES; never
reorder or rename an existing id (renaming re-runs the step).
project_transcript_dir_name(cwd) computes the reshelve destination leaf as
proj-<token>-<hash>, where <hash> is a 32-bit FNV-1a digest computed over the UTF-16
code units (short_path_hash) so it is byte-identical to the TS implementation.
Feature flags
induscode::boot participates in the merged crate's feature set:
| Feature | Effect |
|---|---|
default = [] |
Headless boot. Compiles with --no-default-features so the whole binary builds without the console/addon crates; the repl runner reports that the interactive surface needs tui and exits non-zero, while oneshot/link still run |
tui |
Mounts the live ratatui surface in ReplRunner::run via console::mount_console |
swarm |
Compiles boot::delegate and bridges subagent profiles onto the deck task card |
rustls |
Propagates rustls to launch / shell-app / llmgateway |
Public exports
| Name | Kind | Source | Purpose |
|---|---|---|---|
boot_run |
async fn | boot/mod.rs |
The orchestrator the binary entry calls; returns ExitCode |
drain_closables |
async fn | boot/mod.rs |
Drains teardown callbacks in reverse |
BootContext / BootIo / Closable / OutputSink / InputSource |
struct/trait/type | boot/contract.rs |
The async orchestrator seams |
Invocation / RunnerId / StartupResources |
struct/enum | core::boot_contract (re-exported) |
The routing projection, mode enum, and resolved graph |
Stage / BootPipeline |
trait/struct | boot/contract.rs |
The async stage + the fold |
Runner / RunnerRegistry |
trait/struct | boot/contract.rs |
The runner contract + total registry |
stages |
fn | boot/pipeline.rs |
Builds the five-stage BootPipeline |
apply_upgrades / UpgradeReport |
fn/struct | boot/pipeline.rs |
The upgrade driver and its report |
registry |
fn | boot/runners.rs |
Builds the [repl, oneshot, link] registry |
ReplRunner / OneShotRunner / LinkRunner |
struct | boot/runners.rs |
The three runners |
resolve_startup_resources / build_startup_resources |
fn | boot/resources.rs |
The resource graph assembly |
session_scope_dir / oneshot_prompts |
fn | boot/resources.rs |
Cwd-scoped session dir + the one-shot prompt list |
ReadStateStore / CheckpointStore / DiskMemoryStore |
struct | boot/resources.rs |
The per-session safety stores |
create_read_state_store / create_checkpoint_store |
fn | boot/resources.rs |
Mint fresh per-session stores |
AuthVault / create_auth_vault / AuthRecord |
struct/fn/enum | boot/resources.rs |
The disk credential vault |
memory_dir_for / load_memory_doc / truncate_entrypoint_content |
fn | boot/resources.rs |
Memory-dir helpers |
register_closable |
fn | boot/resources.rs |
Push-side of the closables guarantee |
build_addon_wiring / AddonWiring / concat_addon_tools / wrap_tools_with_addons / wrap_one_with_addons |
fn/struct | boot/addon_wiring.rs |
The addon host activation seams |
build_delegate_runner / SwarmDelegateAdapter |
fn/struct | boot/delegate.rs (feature swarm) |
The deck delegate runner bridge |
Examples
Interactive REPL (the default / fallback runner):
# bare command line -> select returns ReplRunner
indusr
# resume the newest session in this cwd before the console mounts
indusr --continue
# (-c is the alias)
indusr -c
# resume the most recent session in this cwd
indusr --resume
One-shot to stdout:
# clean text to stdout (OutputMode::Json -> RunnerId::Oneshot)
indusr -p "summarize the build script"
# force the interactive session even with a prompt
indusr -i "summarize the build script"
Headless JSON-RPC link:
# OutputMode::Rpc -> RunnerId::Link: session_ops over stdin/stdout
indusr --json
indusr --rpc
Flags reaching the conductor:
indusr --model anthropic/claude-sonnet-4-5 --thinking high \
--tools read,write,bash --append-system ./extra-prompt.md \
--mcp ./mcp.json --account work "refactor this module"
# resolve_model_id picks the explicit --model; build_session_tools allow-lists by
# canonical name; load_mcp_capabilities grafts ./mcp.json; compose_session_briefing
# appends the file; resolve_thinking narrows "high" to ThinkingLevel::High;
# build_key_resolver scopes credential lookup to the "work" account.
Programmatic credential vault use:
use induscode::boot::resources::{create_auth_vault, AuthVault};
use std::path::PathBuf;
let vault: AuthVault = create_auth_vault(PathBuf::from("/Users/me/.indusagi/agent/auth.json"));
vault.put_api_key("anthropic", "work", "sk-...", true); // make_default
let key = vault.read_usable_key("anthropic", "work"); // Some("sk-...")
Key concepts
- Immutable stage pipeline —
stages()builds aVec<Box<dyn Stage>>;BootPipeline::runfolds aBootContextthrough them, each returning a fresh successor via struct-update. Pipeline is data, not control flow. - Total runner dispatch —
registry()is[repl, oneshot, link];RunnerRegistry::selectreturns the firstacceptsmatch, withReplRunneras the guaranteed total fallback (differs from the framework's fallible selector because help/version/list-models are intercepted inbin/main.rsfirst). Adding a mode is a one-line table edit. - Console mount inversion —
ReplRunnerhands the conductor tomount_console, which owns the blocking ratatui loop and returns on exit; behindtui, headless builds compile the mount out and route non-interactive work through oneshot/link. - Shared conductor factory —
build_conductor(ctx)centralises the model/thinking/tools/MCP/addons/briefing/permission/persistence choreography all three runners share, wiring the deck, the gate, the key resolver, the session store, and the diagnostics engine onto oneConductor. - Per-session safety stores —
ReadStateStore(read-before-edit gate) andCheckpointStore(rewind) are minted per session ontoDeckFramework, keyed on lexically-normalised absolute paths. - Two-path credentials — the primary
build_key_resolverper-call resolver over theAuthVault(nounsafeenv mutation) plus the gateway env fallback when it returnsNone. - Idempotent profile upgrades —
apply_upgradesfolds the orderedUPGRADESregistry using a.upgrade-state.jsonmarker; each step runs at most once, failures are non-fatal warnings retried next launch. - Guaranteed teardown —
drain_closablesruns everyClosablein reverse, swallowing individual failures, regardless of how the runner resolved.
See Launch for the flag grammar and the no-session verbs,
Conductor for the session engine,
Capability Deck for provision_deck,
Channels for the oneshot/link transports,
Addons for the extension contract, and the
Architecture overview for how boot sits on top of every
subsystem. The framework seams ported here live under
indusagi::shell_app and indusagi::runtime. For
parity with the other editions see the TypeScript and
Python boot docs.
