Ktesio

Architecture

Ktesio ships a single kt binary, built from a Cargo workspace. The CLI keeps domain logic small and file-based so users can understand and repair project state manually when needed.

Workspace Layout

crates/
├── kt/                     # package "ktesio" — the shipping kt CLI (all current behavior)
├── ktesio-engine/          # engine library (registration + adapter resolution live)
├── ktesio-adapter-api/     # Adapter Contract: trait, per-OS capability + metering types, manifest schema
├── ktesio-adapters-hermes/ # native adapter home (Hermes gateway builtin, launchable)
└── ktesio-conformance/     # adapter conformance kit: the TCK harness + mock adapter + fake agent

kt may depend only on ktesio-engine's public API (plus ktesio-adapter-api types and the ktesio-adapters-hermes builtin); CI enforces that dependency boundary. ktesio-adapter-api depends on nothing internal — it owns the Adapter Contract types and the adapter.toml manifest schema (with validation), versioned under a contract-version constant (frozen at v1, 1.0.0, since story 6-6: the engine refuses a manifest whose contract major differs from the engine's, naming both versions and the rule — the policy lives at the Adapter Contract page). The engine consumes that crate's parsed form and defines no schema of its own. One deliberate hexagonal-boundary exception, stated plainly: the ENGINE takes a normal dependency on ktesio-adapters-hermes (engine → adapters-hermes, CI-gated by the AD-2 allowlist with its rationale inlined at the gate) — a concrete, launchable adapter lives inside the core crate because the builtin native-adapter table (AD-3) is code the engine's native() matcher consults to resolve --kind hermes; every other adapter stays outside the engine as manifest data. The ktesio-conformance kit is a dev/test fixture: the engine and kt reference it as a dev-dependency only, so it never appears in the shipping dependency graph (a normal edge would trip the boundary gate). Story 6-4 made it the home of the Conformance Test Kit (TCK, FR-27): a public library entry point (run_conformance / run_mock_conformance) that registers ANY adapter with a fresh engine, drives every contract-section suite against it, and returns a machine-readable per-section compliance report (pass / fail / not_applicable, each with a reason derived from the adapter's registered declaration). To make that API possible the kit itself takes the engine as its own (outbound) dependency — an edge nothing that ships traverses, so the boundary gate stays green. Third-party adapter crates add the kit as a dev-dependency and assert on the report from their own #[test]; the kit's Hermes pass (in the engine's tests, under the recorded hermes_shim sandbox) proves the shipping builtin conforms to every section its declaration makes applicable. The ktesio-adapters-hermes native builtin declares the real Hermes gateway launch (hermes gateway run --external-supervisor) and maps memory.dir onto the agent's HERMES_HOME; story 6-2 made it launchable end to end. Because the edge is a NORMAL dependency (kt → engine → adapters-hermes), the hermes adapter ships compiled into EVERY kt binary — the builtin table is code, so the kind resolves with no plugin, download, or runtime discovery step.

Engine modules

The engine follows a hexagonal layout (domain core + ports + backing implementations). Registration and the agent lifecycle (start/stop) are live:

crates/ktesio-engine/src/
├── lib.rs      # re-exports the public API (the Embedding Interface)
├── engine.rs   # the async Engine handle + its blocking() facade (owns the tokio runtime + the supervisor)
├── adapter/    # adapter resolution: native builtins + manifest loader/validator; also resolves the start launch (exec/args/env)
├── domain/     # core: LifecycleState, the transition table + events, AgentInstance, the Registry service, the Supervisor
├── ports/      # hexagonal ports: StateStore + ProcessBackend traits (+ SpawnSpec/StopOutcome/ProcessStatus/errors)
├── backends/   # the ONLY OS-conditional code: unix/ (process groups + signals) and windows/ (Job Objects), selected per OS
├── store/      # SQLite StateStore implementation + schema/migrations (internal)
├── paths.rs    # engine-only path authority (state dir + Agent Home), resolved cross-platform
└── time.rs     # RFC 3339 UTC timestamp formatting

The engine is the sole path authority: it computes the state-directory location and each Agent Home layout; kt receives paths from the API and never constructs them. All registry and lifecycle state lives in one SQLite database (WAL journaling, synchronous=NORMAL, foreign keys on) under the engine state directory; bulky per-instance artifacts live as files inside each Agent Home. Errors use thiserror inside the engine and are wrapped into miette diagnostics in kt.

Because nothing durable lives in volatile memory, that one SQLite database is also what makes Fleet state survive an engine restart or a machine reboot (FR-10). A reboot is simply the "all processes gone" case of engine-crash recovery: the on-disk database and Agent Homes are untouched, so on the next Engine::open the orphan reconciliation runs with zero live matches and every previously-running instance is honestly reconciled to failed (never left as a phantom running row), while cleanly-stopped instances stay stopped and every registration, restart policy, and restart count survives byte-intact. The durability loss bound is ≤1s: each state mutation is one committed transaction under WAL + synchronous=NORMAL, so at most the last in-flight transaction can be lost. The Usage Ledger (see "Metering & the Usage Ledger" below) is held to the same bound — each usage event is one committed transaction.

The Fleet is observable through kt agent list (a human table) and kt agent list --json / kt agent show <name> --json (a machine-readable document). The JSON is a versioned serde struct carrying a schema_version and reusing the same domain types the engine's transition-event log serializes, so kt --json and the Host event stream (Engine::subscribe(), story 7.2) stay one schema rather than forking into two dialects (AD-14). Each listing opens the engine and reads live persisted state, so any committed transition is reflected on the next listing (well under the 2-second freshness bound) with no cache to invalidate. The usage value now carries real token totals from the Usage Ledger (see "Metering & the Usage Ledger" below) and the active Metering Source is shown in Fleet detail; budget now carries the real token budget — the configured per-run/cumulative ceilings, the Breach Action, and the remaining tokens per scope (see "Budget enforcement" below) — or an honest typed absence ( in the human table, null in JSON, never a fabricated number) for an instance with no budget configured. When a Rate is configured, the same detail additionally carries the derived dollar cost, the Cost Cap, and dollars-remaining per scope — every figure labeled an estimate, as integer micro-dollars in JSON (never a $ string on the wire) and rendered through the one currency module for humans (see "Dollar cost & the Cost Cap" below); with no Rate those dollar fields stay a typed absence, and a one-line stderr note states that honest boundary.

Registration resolves an adapter before any state is written. A native adapter is selected by kind (--kind) from a small builtin table; a manifest adapter is loaded from a directory or file (--manifest), its adapter.toml parsed and validated by ktesio-adapter-api. The adapter's per-OS Capability Declaration and Metering Source are validated first — an adapter with no capabilities or no viable metering source is rejected, and nothing is written — then the row and Agent Home are created. The effective (current-OS) Capability Declaration is projected as data (via a runtime OS identifier, never conditional compilation) and persisted as a JSON snapshot in the Agent Home, so kt agent show can render it. That same registration snapshot also persists the adapter's resolved start launch — the [lifecycle.start] exec/args/env — through a dedicated LaunchSnapshot DTO (the live StartLaunch stays intentionally non-Serialize, so a post-mapping launch carrying resolved secret cleartext can never be serialized — AD-10; the DTO mirrors the effective-config snapshot's serialize-a-DTO-not-the-live-type discipline). At start the supervisor uses this snapshotted launch instead of re-reading the manifest, which removes a fragile start-time re-read (on some hosted CI runners the re-read returned empty args, so the agent spawned with the right binary but no arguments); a snapshot that predates the field, or a native adapter (which has no launch), falls back to the manifest re-read. A deliberate consequence is that the launch is frozen at registration: editing a manifest's [lifecycle.start] after an instance is registered has no effect on that instance until it is removed and re-registered — the same manifest always yields the same launch for the life of the registration (a determinism improvement, and the intended source-of-truth-as-of-registration semantics).

Metering & the Usage Ledger (AD-7 / AD-6 / FR-19)

Consumption is tracked from day one. Every registered adapter declares a viable Metering Sourceself-reported (the agent forwards its own usage accounting) or engine-observed (the engine intercepts the agent's model traffic) — resolved at registration and persisted on the adapter snapshot. The engine ingests usage per that declaration into a per-instance Usage Ledger: the append-only usage_events table in the one SQLite store, one committed transaction per event (the same ≤1s durability substrate as lifecycle state).

The self-reported channel reuses the log capture the engine already owns: the agent emits KTESIO_USAGE {json} sentinel lines on its stdout (the JSON carries sequence, input_tokens, output_tokens — snake_case), the engine captures them into the per-instance agent-output log, and the supervisor's reaper drains the newly-captured tail, parses each line, and records it. The drain's read cursor only advances past bytes whose events are DURABLE: a ledger write failure parks the cursor (the failed event is retried on the next pass, and already-committed neighbors re-drift safely into the dedup key — no double-count) and emits a diagnostic, so usage is never silently dropped. A malformed usage line is a diagnostic (skipped), never fatal and never mixed into kt's own output. The ingestion side is a hexagonal port (UsageSource, distinct from the declaration enum), which has two implementations behind it: the self-reported drainer, and the engine-observed source (below).

Engine-observed metering (AD-7 / FR-19) meters agents that report nothing themselves — governance never depends on the agent's cooperation. Per AD-7's ratified v1 definition, the engine runs a per-instance loopback-only HTTP forward listener: at the starting transition it binds 127.0.0.1:0 (an OS-picked ephemeral port, loopback only — a non-loopback bind is a hard error, never a silent widening), and hands the adapter that http://127.0.0.1:<port> address. The adapter points the agent's OpenAI-compatible base_url at it through its existing config-mapping (an env target such as OPENAI_BASE_URL, mapped from the engine-injected metering.base_url key — the same story-2-2 mechanism, so no new contract surface). The listener is a transparent forward proxy: it forwards each request to the operator-configured real upstream (metering.upstream_base_url), relays the response back to the agent faithfully (status, headers, body), and skims the standard OpenAI usage object (prompt_tokens/completion_tokens) out of a completion response — a non-streaming JSON body or a streaming response's terminal SSE frame — into the same ParsedUsage the self-reported channel yields. Because the agent supplies no sequence (it does not know it is observed), the engine mints a per-Run monotonic ordinal per observed completion, preserving the UNIQUE(instance_id, run_id, sequence) dedup invariant. Parsed usage funnels into the same single ingestion choke point (below), tagged engine-observed, so token budgets and dollar caps enforce on it with zero re-plumbing. The listener is a portable async task on the engine's tokio runtime (bound to the Run — torn down at the terminal transition, no orphan listeners); it lives in the engine core (src/metering/), never in backends/, because loopback TCP + HTTP is identical on every OS with no OS-conditional code. Security (NFR-6 boundary, stated honestly): this proxy carries the agent's model traffic including its API key upstream; it relays that traffic faithfully but leaks only the two parsed integer token counts into the ledger — never a request/response body, header, URL, or key into any engine log, event, ledger row, or error message. The loopback-only bind is defense against accidental external exposure of that traffic, not a hardened sandbox. Scope (epic-12 landed the ratified extensions): the upstream is forwarded over plain HTTP or HTTPS — an https:// upstream dials directly through a vendored rustls+ring TLS stack (no system TLS library; one rustls+ring pair unifies with kt's own download client; the trust roots are the vendored webpki-roots store, story 12-3), and streaming completions are metered: a forwarded request that already asks to "stream": true gets stream_options.include_usage injected (the ONE upstream-visible relay modification; an agent-set stream_options wins), and the terminal SSE usage frame is parsed by an O(1)-memory scanner into the same choke point (story 12-2). Non-OpenAI provider usage schemas remain deferred behind that named parse seam, under AD-7's [ASSUMPTION: OpenAI-compatible usage JSON covers v1 targets]. Durability (story 12-4): the observed drain has the self-reported channel's AI-41 treatment — a ledger-write failure parks the minted events and retries the exact same events (never re-minted, so the dedup keys are stable) with a diagnostic; after 3 consecutive failures at the same front event the poisoned event is skipped loudly (an announced loss; the rest keep counting), and the terminal drain (stop / crash-reap) announces any final loss explicitly — the observed channel never wedges and never silently drops under store failure. Across an engine crash + adoption an already-running observed instance is left un-observed, and the impact is bigger than a metering gap: the adopted agent's base_url still points at the dead loopback listener port, so its model calls break — they hit the dead port and fail with a connection-refused error. This fails loud (a transport error the agent surfaces), never a corrupt or silently-wrong response — and adoption itself says so: the engine cannot rewrite the running child's already-injected environment, so at adoption it emits a diagnostic naming the instance and the stranded observed listener, with the remediation that a stop→start re-anchors the agent (the fresh start binds a new listener and re-injects a live base_url). The strand is never left silent; the deeper fixes (re-launching an adopted observed instance at open, a stable per-instance listener port, or the Epic-7 daemon owning the listener) remain unratified follow-ups. A self-reported instance is unaffected (it needs no listener).

A Run is the span from a starting transition to the next terminal state (stopped/failed) of an Agent Instance. The supervisor mints a fresh Run id at each starting (an operator start or a Restart-Policy restart both open a new Run), holds it in memory alongside the process handle, and stamps it on every usage event ingested during that Run — so per-run totals never bleed across a crash/restart boundary. A per-run total is the sum over usage_events scoped to (instance, run); the cumulative total sums all of the instance's rows.

Ingestion is idempotent by construction: each event carries the agent-supplied sequence ordinal, recorded under a UNIQUE(instance_id, run_id, sequence) index, so a delayed or replayed batch is recognized and skipped, not double-counted — "no double-count" is a database invariant, not fragile application bookkeeping. All usage writes funnel through one engine choke point (the sole usage_events writer — no other code path may mutate the ledger), so token-budget enforcement (Epic 3.2) slots into that same commit path with no re-plumbing. The committed event also rides the versioned event schema as a usage update struct, published onto the bounded event bus at its ledger commit (story 7.2), so kt --json and the Host stream stay one schema (AD-14).

Fleet detail surfaces the ledger honestly: usage shows real cumulative and current-Run token totals that equal the ledger exactly, the active Metering Source is visible, and budget now shows the real token budget (see "Budget enforcement" below). When a Rate is configured the same detail also shows the derived dollar cost and Cost Cap (see "Dollar cost & the Cost Cap" below); with no Rate the dollar fields stay a typed absence (tokens only). Because the ledger touches the adapter-facing metering surface (the documented usage-line channel), the Adapter Contract version took an additive minor bump at 3-1; budget/dollar enforcement and engine-observed metering are engine-side (the listener, the base_url injection, and the observed source are all engine-internal, and both MeteringSource variants plus the config-mapping already existed in the contract), so they took no further bump at the time. (Story 6-6 has since frozen the contract at v11.0.0 — with the engine negotiating contract majors at registration; the versioning and deprecation policy is normative at the Adapter Contract page.)

Budget enforcement (AD-7 / FR-18 / FR-21)

Token consumption is bounded even when nobody is watching. An operator sets a Token Budget per Agent Instance at two scopes — per-run and cumulative — as engine-namespace config values (budget.tokens.per_run, budget.tokens.cumulative), plus a Breach Action (budget.breach_action) among pause (the ratified default), stop, or warn. These are ordinary layered-config keys (validated at write time: a non-numeric budget or an unknown action is rejected before anything persists, never silently defaulted), so a budget is inspectable and changeable while running and applies immediately — the enforcer reads the current resolved config on every ingestion, not a value frozen at start.

Enforcement is the last stage of the one metering pipeline, and it runs inside the same commit path as the ledger write (the AD-7 rule): the instant a fresh usage event commits at the sole ingestion choke point, a pure BudgetEvaluator compares the just-committed per-run and cumulative totals against the resolved budget and returns a decision. The evaluator is total, I/O-free, and unit-tested exhaustively; it decides, the supervisor acts — so the boundary/scope logic is testable without spawning anything. The threshold is : consumption reaching a ceiling of N (total ≥ N) is the breach, so the guardrail fires at the ceiling, not one token past it. Both scopes are enforced on every event; when both would trip, the tighter per-run scope is reported (the action is the same either way). This is the sole enforcement site — no other code path evaluates a budget or triggers an action (the companion to the ledger's single-writer invariant), so the enforcement race the AD explicitly forbids can never open between "usage recorded" and "budget checked". When a token ceiling and a dollar Cost Cap are both configured, the supervisor's one enforcement site evaluates the token ceilings first, then the dollar caps — so with both breached, the token breach wins the pause (the dollar breach is still recorded, subject to the per-Run (dimension, scope) latch on its own Run). Story 6-3's engine e2e (phases H–L) proves the dollar-only path in isolation: with the token ceiling lifted, a dollar-cap breach drives the pause with cause BudgetExceeded{Dollars}.

On a breach the supervisor first records the breach event — a versioned, schema_version-stamped serde struct (instance, run, scope, dimension, limit, observed, action, metering source, timestamp — the dimension is tokens or dollars, the dollar fields carried additively since story 3-3) appended to a durable per-instance breach log — before, and independently of, the lifecycle side-effect, so a best-effort, unsupported, or failed pause never loses the breach record (the FR-21 "always recorded regardless of action" invariant). It then executes the Breach Action through Epic 1's existing lifecycle: pause drives running → paused via the existing pause path (honoring the adapter's pause Capability Declaration exactly — a guaranteed pause suspends, a best-effort pause transitions with its honest posture, an unsupported pause is surfaced honestly and is not faked and not silently escalated to stop); stop drives running → stopping → stopped; warn performs no transition at all. A budget breach is a new cause (TransitionCause::BudgetExceeded, carrying the scope/limit/observed) on those existing transitions — not a new state and not a new transition-table edge — so the lifecycle log itself explains why while the standalone breach event is the subscription payload (published on the event bus since story 7.2). The cause rides the existing pause seam: a best-effort pause initiated by enforcement carries the BudgetExceeded cause instead of the generic best-effort qualifier (the cause-override wins — the lifecycle log explains why the instance paused, not just that it paused; story 6-3's e2e asserts exactly this on the hermes builtin). Enforcement is best-effort to the Run: a lifecycle error is a diagnostic on the engine log/stderr, never a crash of the supervision loop (the ledger's "ingestion must never crash the supervisor" rule extends to enforcement) — e.g. a pause breach on an instance that is already paused records the breach and emits the "pause could not be honored" diagnostic rather than failing the run.

Fleet detail's budget cell reports the configured ceiling(s), the Breach Action, and the remaining tokens per scope (ceiling − current total, saturating at zero) — computed from the same ledger totals usage reports, so it equals the ledger exactly. An instance with no budget configured shows an honest absent budget ( / null), never a fabricated ceiling.

Dollar cost & the Cost Cap (AD-7 / AD-8 / FR-20 / FR-21 / FR-23)

A Rate turns metered tokens into an enforced dollar cap, reusing the token machinery above in front of a token→dollar derivation. The operator supplies a per-instance Rate — input and output prices in $/1M tokens (cost.rate.input, cost.rate.output) — and a dollar Cost Cap at the same two scopes (budget.dollars.per_run, budget.dollars.cumulative). All four are engine-namespace layered-config values written as dollar strings (e.g. "3.00") parsed to integer micro-dollars at write time; a malformed or sub-micro-precision value is rejected before anything persists (never silently defaulted), and a value hand-edited past that gate degrades to "absent" on read rather than crashing ingestion.

Money is integer micro-dollars, never f64 (Micros(i64), 1e6 micros per dollar — the billing-correctness crux, AD-8 plus the 3-1 u64→i64 lesson): a cost cap enforced on a lossy float is not a trustworthy guardrail, and $/1M rates yield sub-cent per-token costs that cents would truncate to zero. The cost derivation is a pure functioncost = input_tokens × rate.input / 1e6 + output_tokens × rate.output / 1e6, each direction priced independently — with two disciplines: the tokens × price multiply uses a u128 intermediate that saturates (a runaway token count pins at i64::MAX rather than wrapping and silently un-breaching), and the divide by 1e6 is round-half-up (deterministic, exhaustively unit-tested). A thin CostEvaluator returns the same BreachDecision/BreachScope/BreachAction the token evaluator returns (the decision's limit/observed are unit-agnostic — micro-dollars here), so the supervisor's one enforcement site fires the same pause/stop/warn action for a dollar breach as for a token breach, at the same threshold, in the same commit path. The per-Run breach latch is keyed by (dimension, scope), so a token breach and a dollar breach of the same scope each fire once per Run.

No retroactive repricing (FR-20): each usage event is priced at the Rate in force when it was consumed. The engine persists the effective Rate onto each committed ledger row (an engine-side column on usage_events, not the adapter-facing UsageEvent wire type), and the derived cost sums each row at its own stored Rate — so changing the Rate re-prices future events only and never re-walks history. A row committed with no Rate (or a pre-existing row) contributes $0.

Inert without a Rate (FR-20): with no Rate configured, dollar features are honestly inert and say so — the ledger derives no dollars (cost is absent, never a fabricated $0.00), a Cost Cap set without a Rate cannot be enforced (it is skipped, not a silent no-op and not a fake breach), and every token feature works fully and unaffected. The Fleet-detail dollar fields stay null/absent until a Rate exists.

Estimate honesty is type-enforced (AD-8 / FR-23): exactly one currency module (domain::cost::render_dollars) turns micro-dollars into a $X.XX human string, and it always appends an EstimateLabel (v1 always estimated; reconciled is a forward seam for provider-confirmed actuals). Every other module passes Micros + EstimateLabel as data; --json and the breach payload carry integer micros + the label, never a pre-formatted $ string (so a Host formats its own currency — AD-14). A currency grep-lint CI gate proves no other module formats a dollar string. Fleet detail surfaces the derived cost, the Cost Cap, and dollars-remaining per scope (saturating at $0) — labeled — when a Rate exists. Because the Rate/cap are operator config and the cost is engine-internal, this touches no adapter surface: no contract-version bump.

Reading usage & cost, per-instance and Fleet-wide (AD-8 / FR-22 / FR-23)

The whole metering machinery above is finally read honestly at two scopes. Per-instance (largely built across 3-1/3-2/3-3, formalized here): each Fleet-detail row (kt agent show/list, human + --json) carries tokens by scope — cumulative and current-Run — the derived dollars (labeled, present only when a Rate exists), the active token budget + dollar Cost Cap, and the per-scope headroom; the rendered/--json numbers are the Usage Ledger sums (never a recomputation that could drift), so totals equal the ledger exactly (FR-22). The wide show view surfaces both token scopes explicitly (cumulative plus "this run" when a Run is active); the narrow list column shows the cumulative scope, and --json carries all four token fields on every surface.

Fleet-wide is the greenfield: a pure FleetTotals aggregate (domain::fleet::FleetTotals::from_entries) sums the already-composed per-instance rows into total input/output tokens across every instance and a total derived dollar figure — computed in one read pass over the Vec<FleetEntry> the Fleet read already built (no second ledger query), using the same saturating integer discipline (u64 for tokens, Micros::saturating_add for dollars — no f64, no wrap). The aggregation rule lives in the engine domain (AD-2); the CLI only triggers it via FleetListing::new(entries) and renders. Three honesty rules are why aggregation is not a trivial sum(): (1) label the estimate — v1 every derived dollar is estimated, so any non-empty Fleet dollar total is estimated (there is no reconciled Fleet total until reconciliation ingestion ships); (2) zero-not-absent — every instance's real token total is counted, a never-metered instance contributing an honest 0 (never omitted, never fabricated), and a no-Rate instance contributing 0 dollars but never claimed to have cost $0.00 (its dollar cost is unknown, not zero); (3) say so when partial — if a metered instance has no Rate, its real consumption has an un-derivable dollar cost the aggregate cannot include, so total_dollars becomes a labeled lower bound flagged dollars_partial and rendered ≈ $X.XX (estimated; some instances unpriced), never presented as the exact Fleet cost (SM-C3 — honesty outranks precision). The dollar shape encodes all three: total_dollars is absent (None) when no instance has a Rate (nothing to estimate — the human footer shows , never $0.00), a labeled value when at least one does, and additionally partial when some-but-not-all metered instances are priced. The aggregate rides the versioned Fleet --json document as a top-level totals object (integer micros + the label as data, never a $ string — AD-14) and, for humans, a Fleet-total footer on kt agent list rendered through the one currency module (the currency grep-lint stays green — the automated proof that no dollar escapes it). Provenance stays visible in the per-instance metering_source rows the total summarizes, so a reader sees whether the Fleet mixes self-reported and engine-observed sources without the aggregate enumerating per-source subtotals. The Fleet total is cumulative only — a per-Run total is meaningful per-instance, not across instances each in their own Run.

Adding the totals object is an additive change to the Fleet document, so the Fleet --json schema version bumps 1 → 2 (a v2 reader parses every v1 document; a v1 consumer that ignores totals still parses instances — the bump is the honest signal that a new first-class field exists, matching the discipline of treating the Fleet document version as the --json contract). The show --json document carries the same version but does not gain a Fleet total — a single instance has no Fleet total, its own usage is its total. This read is entirely engine-side over the existing ledger and cost — no new capture, enforcement, metering source, or proxy, and no ledger write — so the Adapter Contract version is untouched here (the Fleet document FLEET_SCHEMA_VERSION is a different versioned surface from the adapter CONTRACT_VERSION — the two are not conflated). This completes Epic 3 (Cost Governance): the token ledger (3-1), token budgets (3-2), the dollar Rate → Cost Cap (3-3), engine-observed metering (3-4), and now the honest read of usage and cost per-instance and Fleet-wide (3-5).

Unified layered configuration (AD-9)

Configuration is layered TOML with a deterministic precedence: engine defaults < agent-kind defaults < Agent Home instance config < invocation overrides. A pure, I/O-free resolver folds the four layers into a single effective config with a structural, per-leaf merge. Where shapes agree it is a deep merge — setting a.b at a stronger layer overrides only a.b and leaves a weaker layer's sibling a.c intact, so a single override never silently drops sibling keys. Where shapes disagree the stronger layer's shape wins and prunes the weaker layer's orphans: a strong scalar at a.b masks a weak [a.b] subtree (no contradictory a.b="scalar" plus a.b.c=1), and symmetrically a strong subtree replaces a weak scalar — so the resolved tree is never self-contradictory and every surviving leaf's recorded source layer reflects the layer that actually defines it. The same key set at the instance layer overrides the same key at the kind or engine-default layer, every time and on every machine (the resolver depends on no clock, environment, or OS). The resolver records, per resolved key, which layer supplied the winning value; that per-value source layer is now rendered and persisted (see "Effective-config provenance" below).

Config lives as TOML files under path authority, not in SQLite: SQLite remains the registry/lifecycle/ledger store, while the engine owns every config path and is the only reader/writer. The four sources are an embedded engine-defaults constant (present but empty today — the engine seeds only unified keys it can honestly honor, and no engine-wide key is config-controlled yet; the Restart Policy default keeps coming from the engine, not from config), per-kind adapter defaults (absent kinds — including mock today — resolve to an empty layer, never an error), the per-Agent-Home config.toml the registration step already writes (the instance layer kt agent config set edits in place), and an ephemeral invocation-override map supplied at resolve time. A malformed layer surfaces a typed error naming the layer and path, never a panic.

Config is validated at write time: a write of an unknown key outside the reserved agent.* pass-through namespace is rejected before anything is persisted (the instance file is left byte-unchanged), with the nearest valid key suggested via a small edit-distance match over the known-key set — or an honest "no close match" when nothing is near. A write with an empty dotted segment (agent..b) is rejected, and a write that would nest a child under an existing scalar (agent.a.b when agent.a is already a value) fails closed rather than silently destroying the scalar — both leave the file byte-unchanged. Keys under agent.* are the escape hatch for agent-native extras: they bypass the known-key check and round-trip verbatim (a secret:NAME value is stored as an ordinary TOML string here — the reference; it is resolved + masked at start/read per Secrets (AD-10 / FR-14) below). kt agent config set <name> <key> <value> writes to the instance layer — atomically: the updated config.toml is written to a same-directory temporary file and renamed into place, so a crash mid-write always leaves either the complete old or the complete new bytes (never a truncated file), with no temp residue on any path; kt agent config get <name> [<key>] reads the effective (resolved) config, printing the value(s) with per-value provenance to stdout with output discipline (results to stdout, diagnostics to stderr). The instance identity (name, seeded at registration) is filtered from the resolved view, so it is not presented as a settable key.

Unified → native config mapping (AD-9 / FR-12)

Each Adapter maps documented unified keys into the Agent's native mechanism at start time — a config file, an environment variable, or a CLI flag — so an operator configures an agent in one unified vocabulary without learning its per-agent format. The mapping is adapter-declared in one uniform shape (ConfigMapping: unified key → ConfigTarget::{Env, Flag, File}), whose types and validation live only in ktesio-adapter-api (the engine consumes the parsed form and defines no schema — AD-3). A manifest adapter declares it in an optional [config] section of adapter.toml ([config.model] with exactly one of env = "MODEL", flag = "--model", or file = { path = "...", key = "..." }); a native adapter (the builtin mock, later hermes) declares the same shape in code via the AgentAdapter::config_mapping() accessor. An absent [config] section and a native adapter that does not override the accessor both yield an empty mapping — the "two kinds, one trait" invariant. This is an additive, optional extension of the Adapter Contract, so the contract-version constant took an additive minor bump.

The mapping is applied at start, from the resolved effective config, at one seam: where the launch spec (exec/args/env) is built for the [lifecycle.start] template and before the process is spawned, the engine resolves the instance's effective config (2-1's four-layer fold), reads the adapter's mapping, and places each value into its declared native target — an env var goes into the spawned process's environment, a flag is appended to the launch arguments (as two tokens, --model gpt-4), and a file target is rendered into a native TOML file inside the Agent Home (the engine is the sole writer — path authority, and the render is atomic: temp file in the target's directory, one rename — a crash mid-write leaves the previous native file byte-identical and no temp residue). A documented key the adapter maps nowhere is a silent no-op (not every adapter supports every unified key), and a file path that is absolute or escapes the Agent Home is rejected at manifest-load time. Keys under agent.* are delivered verbatim through the same seam — the key-tail after agent. and its value, with no rewriting and no known-key lookup (the recorded convention delivers a pass-through key as an env var named by its verbatim tail). A config-mapped env target that overrides a same-named environment variable already present in the adapter's launch (its [lifecycle.start] env or its code-declared launch) wins (the documented last-write precedence is unchanged — the config value is what the process receives), and the shadow is reported: the start emits one diagnostic on stderr (or the host's diagnostic sink) naming the overwritten variable, so the overwrite is never silent. In effective-config output, each agent.* leaf is rendered as unvalidated (a per-row marker derived purely from the pass-through prefix, so the operator sees which values skipped known-key validation) while a known key is rendered as validated. For a secret:NAME leaf, this seam is where display and delivery diverge: the value placed into the native mechanism is the resolved cleartext (the agent needs a usable key), while every display of the same leaf is masked — see Secrets (AD-10 / FR-14) below.

Effective-config provenance (AD-9 / FR-13)

An operator can see exactly what will apply on next start and where each value came from. The resolver already tags every resolved leaf with the layer that supplied it (engine-default, kind-default, instance, or invocation-override); that provenance is now rendered and persisted. kt agent config get <name> gains a Source column beside the Validated column, naming each value's winning layer, and kt agent config get <name> [<key>] --json emits a versioned document whose per-leaf objects carry { key, value, source, unvalidated } — pure JSON on stdout, sourced from the engine's source tag (the CLI never re-derives a layer). At start, the engine writes a persisted effective-config snapshot — every resolved value plus its source layer — as effective-config.json inside the Agent Home, through path authority (the engine is the sole writer; effective-config snapshots are files in the Agent Home, never SQLite blobs). It mirrors the adapter.json snapshot convention: a dedicated versioned JSON document, written with the same mechanics but at start rather than registration, and overwritten on every successful start/restart so it always reflects the config resolved for the current run (it answers "what will apply on next start"). The write lands right after the native-mapping application and before the starting transition, so a snapshot-write failure rejects the start cleanly with no state change; the snapshot is not written at registration and not deleted at stop. config get continues to resolve live (showing what would apply next start); the persisted snapshot is the durable record for Hosts and debugging. Every surface — the human Source column, --json, and the snapshot — renders each value through one display path, the single choke point at which secret masking hooks (see Secrets below) without touching the rendering call sites.

Secrets (AD-10 / FR-14 / NFR-6)

Secret-classified config values (API keys, tokens) are referenced indirectly and never logged, echoed, or rendered unmasked — safe by construction. A config value whose string form is secret:NAME (a non-empty NAME after the secret: prefix — a bare secret: is ordinary text) is a secret reference: the reference is what is stored in config.toml; the real value is never persisted by the engine.

Resolution (a hexagonal port, AD-10). At start, in the supervisor's start seam (right after the effective config is resolved and before the native-mapping application, so it is still before any persisted state change), each secret:NAME leaf is resolved through the SecretResolver port. v1 composes two resolvers in order: process environment first (secret:OPENAI_KEYstd::env::var("OPENAI_KEY") — the operator's ad-hoc override), then the engine secrets file at <state base>/secrets.toml (a TOML NAME = "value" table — the durable store). A reference resolved by neither rejects the start with a typed error naming the NAME and the resolvers tried (never a value), leaving the instance in its prior state — no half-launch, mirroring how a snapshot-write failure rejects. An OS-keychain resolver is a deferred implementation behind the same port. The resolved cleartext lives only in a SecretString newtype whose Display and Debug both redact ([REDACTED]) and which is not Serialize-derived, so a secret in launch.env/launch.args cannot leak through a {:?} on a launch spec or an event payload; the cleartext is reachable only through an explicit, greppable expose_secret().

The 0600 secrets file, cross-OS. The secrets file is expected at mode 0600 (owner-only). The permission inspection is OS-specific, so it lives only in backends::{unix,windows} (the sole allowlisted #[cfg] home, AD-4). On Unix the resolver reads the file's mode bits and refuses a group/other-accessible file (mode & 0o077 != 0) with a chmod 600 remediation — a world-/group-readable secrets file defeats the guarantee. On Windows unix mode bits do not exist; v1 takes a documented portable posture: it does not attempt a unix-style refusal and instead relies on the default per-user profile ACLs (the state dir lives under the user's profile). This is an honest boundary — it avoids a false pass masquerading as a unix-grade check and avoids a hard failure that would make secrets unusable on Windows; a future ACL-checking resolver can strengthen it behind the same port.

Masking at one choke point; delivery diverges. Because provenance routed the human table, config get --json, and the persisted effective-config.json snapshot all through the single ResolvedValue::display() path, masking a secret there masks all three at once (a secret leaf renders secret:****, never the cleartext). The one deliberate exception is delivery: the native-mapping application places the resolved cleartext (via expose_secret()) into the adapter's native env/flag/file, because the agent needs a usable key. So the same leaf shows a mask in config get/the snapshot/logs while the agent's private native config holds the real value. That rendered native config file inside the Agent Home holds cleartext by necessity — an accepted boundary: the Agent Home is process/filesystem-isolated (FR-2), not a security sandbox (NFR-6); the file is the agent's own secret to hold, exactly like the 0600 secrets file. A secret delivered to an env target is likewise cleartext in the child process's environment (an accepted, home-scoped boundary). One delivery target is stricter and called out explicitly: a secret mapped to a command-line flag target is passed as an argv token, and argv is world-readable cross-user on the host process list (ps, /proc/<pid>/cmdline) — a wider exposure than the filesystem-isolated env/file boundaries above. This too is accepted (the agent needs a usable key and Ktesio's own surfaces stay masked), but operators should prefer env/file targets for secret-carrying keys and treat a secret-in-flag mapping as visible to any local user. Ktesio makes that steering visible twice, warn-only (never a rejection): at config set time, putting a secret:NAME value on a flag-targeted key succeeds but prints a stderr warning naming the key, the argv exposure, and the env/file alternative; and at start, the engine emits a one-line diagnostic naming every key whose resolved cleartext was delivered into a flag target — riding the same diagnostic channel (stderr by default, the host's sink when installed), never carrying the value itself. Ktesio's guarantee is that a secret never leaks through Ktesio's own logs, event payloads, --json, or the snapshot — proven by a no-leak test matrix (see docs/testing.md). Event payloads (TransitionCause details) name the binary/kind/exit-status, never env values, so a crash/launch-error diagnostic cannot carry a resolved secret.

--reveal is the sole un-mask. kt agent config get <name> [<key>] --json (and the human table) masks secret values by default; --reveal is the only explicit acknowledgment that emits the unmasked value in machine-readable output. It re-resolves the secret live through the engine (the CLI never resolves secrets itself, env → the secrets file, at read time) and un-masks both --json and the table symmetrically; a resolution failure under --reveal is a stderr diagnostic, not a crash. Because the resolution is live at read time, a revealed value may differ from what a currently-running instance resolved at its start (that run captured the value that was live then; --reveal shows what would resolve now). --reveal affects only the on-demand read surface — it never un-masks the persisted snapshot, the logs, or event payloads (those are always masked, no flag touches them). No new interactive prompt: --reveal is a flag.

Async engine + blocking facade (AD-13)

The engine runs its supervision core on a tokio multi-thread runtime. The public Engine API is asynchronous; blocking filesystem and SQLite work runs on tokio's blocking pool (spawn_blocking), since rusqlite is a synchronous C binding that must never stall an async worker. A thin blocking() facade wraps each async method in runtime.block_on(...); kt uses that facade and stays a synchronous binary (no async main, no TTY or prompts inside the engine — interactivity lives only in kt). A Host embedding the engine with its own runtime calls the async methods directly.

Embed-clean guarantees (FR-34, story 7.3 — verified, not assumed). Three durable instruments in crates/ktesio-engine/tests/embed_clean.rs keep the embedding story honest in CI. The collision test runs TWO independent engines in ONE process — different hermetic roots, one thread each over engines opened and subscribed on the main thread (so a setup failure fails the test directly instead of hanging a sibling on the barrier), with a barrier-synchronized start; individual-step interleaving is scheduler luck and is not claimed — the isolation proof does not depend on it. It drives the full UJ-3 flow through the facade with a story-7.2 subscriber on each engine, and asserts both flows complete under the shared expectations while every observable stays isolated: each subscriber's received streams equal its own engine's committed instance.log/breaches.log/ledger exactly (so a cross-engine leak would be an extra, misordered event), each root's database holds exactly one instance, and the two engines' Run-id sets are disjoint on BOTH event families (usage and breach events alike carry run_id). No global process state holds by audit: the engine never reads stdin, never detects a terminal, never mutates the environment (reading KTESIO_STATE_DIR is fine), never installs a signal/panic/console/exception handler (including tokio::signal), builds its tokio runtime only inside Engine::open (per engine, aborted on drop), and holds exactly ONE named allowlist entry: the RUN_NONCE global static in domain/usage.rs, the RunId uniqueness tie-breaker (a monotonic counter never read for behavior; it guarantees PER-PROCESS uniqueness, exactly what a multi-engine single-process host needs — cross-process uniqueness is not claimed, since separate roots keep separate ledgers). The engine's two best-effort stderr diagnostics (the memory-delivery notice and the enforcement breadcrumb — never prompts, never stdin, never stdout; a host's stdout parsing is never polluted) were closed by story 10.2's host-provided diagnostic sink rather than allowlisted forever: both route through the single Supervisor::emit_diagnostic choke point (stderr survives as the no-sink default, byte-identical to the pre-sink engine, and a host installs a writer via Engine::open_with_diagnostics / with_diagnostics), the print-site allowlist is EMPTY so any new raw print fails the audit, and the sink plumbing carries positive count==1 pins (the choke point, both diagnostic routes, the single io::stderr() default arm) so a diagnostic cannot silently disappear, be reworded, or grow a second direct stdio writer. Facade coverage holds by inventory audit: every pub async fn in the whole production crate (not just engine.rs) has a Blocking counterpart — matched by NAME AND SIGNATURE — that bridges via block_on, with the bridged subscribe and the story-10.2 sink installer with_diagnostics as the only intentional sync-side extras — and a companion source audit asserts kt's own sources never touch an engine async API or a runtime (no .await, no tokio, no block_on, matched on comment- and string-cleaned code lines) and that kt's manifest declares no tokio edge; the build-level boundary gate stays the full proof for story 7.4. Any regression — a new stdin read, prompt, TTY detection, env mutation, handler, global static, uncovered or signature-drifted async method, or a direct async call from kt — fails CI.

The event bus (AD-14 / FR-33, story 7.2)

A Host OBSERVES the engine, not just drives it: Engine::subscribe() (sync, lock-free — the bus handle lives outside the supervisor mutex) hands out a broadcast receiver over one bounded channel, and Blocking::subscribe() wraps the same thing in an EventSubscription whose recv bridges through the engine runtime for synchronous consumers. The payload is the EngineEvent wrapper — a kind-tagged envelope over the EXISTING versioned AD-14 structs verbatim (TransitionEvent, BudgetBreachEvent, UsageUpdateEvent; each stamps its own schema_version, the wrapper adds none). The bus is fed at the SAME commit points where the event logs append — a transition after its instance.log append, a breach after its breaches.log append, a usage update after its ledger row commits — so a subscriber sees exactly the committed truth, in commit order (per-instance FIFO; publish order == durable append order). Publishing never blocks and can never fail supervision (a send with no receivers is swallowed), and the channel is bounded (EVENT_BUS_CAPACITY, a shared per-engine ring): a stalled subscriber cannot back-pressure supervision — past the capacity it observes Lagged(n) for the dropped prefix and resyncs at the tail, while what it missed stays readable through the query APIs (transition_events, budget_breach_events, ledger reads). Delivery is honestly at-most-once in the crash window (a crash between an append and its publish loses that one event from the stream; the durable logs stay complete — and since story 10.3 resync_events backfills the committed records as bus payloads in one call, so the window is recoverable, not permanent).

Lifecycle: transition table, supervisor, and per-OS process backends (AD-4, AD-15)

The agent lifecycle is a data-driven state machine: one pure transition table maps (state, command) to the next state or a single uniform InvalidTransition error, so an invalid command (for example stop on a stopped instance) is rejected identically for every adapter — the rejection comes from the shared table before any adapter code runs. The command set is start, stop, pause, and resume; the wired command edges are registered/stopped → starting, starting → running (adapter ready) or → failed (launch error), running → stopping → stopped, running → paused, paused → running, paused → stopping (a paused instance is stoppable), and failed → starting (a failed instance is restartable — by the Restart Policy executor or an explicit start). One edge is event-driven rather than a command: running → failed (crash detected) is applied by the supervisor's reaper when a supervised process exits without a requested stop, exactly like starting → running / stopping → stopped. A supervisor owns the running instances' process handles in memory for the current engine lifetime and drives each transition: apply the table, act via the process backend, persist the new state, and record a transition event. Each transition is recorded to a per-instance JSON-Lines event log in the Agent Home (the agent's own stdout and stderr are captured directly to their own crash-immune files — agent.log for stdout, agent-stderr.log for stderr — with a separate, attributed and rotated output.log view that interleaves both streams plus engine transition lines, ordered and timestamped, for kt agent logs); the escalation from a graceful to a forced stop is recorded there too, as is the best-effort qualifier on a cooperative pause (below), a crashed cause on a detected crash, and a restarted cause (carrying the restart count + the backoff waited) on each Restart Policy restart.

All process control goes through one ProcessBackend port whose methods speak in domain terms, never OS syscalls. The per-OS implementations are the only place in the workspace that uses OS-conditional compilation: on Unix each agent is spawned into its own process group and stopped with SIGTERM, escalating to SIGKILL across the whole group after a configurable window (default 30s) so no child process survives; on Windows each agent runs in its own Job Object (a detached spawn's job is created WITHOUT kill-on-close, so closing the engine's handles kills nothing) and is stopped with TerminateJobObject, killing every process in the job — descendants included. The port also exposes a durable start-time fingerprint ({ pid, start_time }) and an adopt(fingerprint, detached) re-acquisition, whose per-OS sources live only in the backends: the process start-time comes from /proc/<pid>/stat field 22 on Linux, libproc proc_pidinfo(PROC_PIDTBSDINFO) on macOS, and GetProcessTimes creation time on Windows.

Survival — crashes and orphans (AD-5 / AD-15). A crash is detected by a periodic reaper (a tokio interval owned by the engine, ~250ms, calling the sync poll_once off the blocking pool) that reuses the same poll liveness check and reacts to an unrequested exit with the event-driven running → failed edge. A per-instance Restart Policynever or the default on-failure (persisted per instance; the layered-config engine is Epic 2) — then drives recovery: on-failure restarts with exponential backoff (1s base, ×2 per consecutive failure, capped at 60s) and a visible restart count, stopping after exactly 5 consecutive failures with the crash-loop reason stated; a clean run resets the count. Before a spawned process is treated as supervised, a write-ahead spawn record { instance id, pid, start-time fingerprint, policy, restart count } is committed to SQLite in one transaction ("no spawn without its record committed first"), and it is cleared on a clean stop. On Engine::open the engine reconciles every record against live processes: a matching start-time fingerprint is adopted back under supervision (state stays running/paused, so stop/pause/resume work again and a subsequent stop truly terminates it), while a record whose process is gone — or whose PID was reused by a different process — is honestly reconciled to failed with the last-known cause, never left as a phantom running row. An adopted process carries its verified start-time on the handle so the reaper re-checks that fingerprint on every liveness poll, not just a bare PID — so if the adopted agent crashes and the OS recycles its PID within a poll interval, the crash is still detected rather than masked by the recycled process. Three honesty refinements complete the picture: a handle the backend can no longer poll is not trusted forever — after ten consecutive poll errors the reaper treats it as crash input and lands the instance failed with a cause naming the persistent poll failure (a clean read resets the streak). Because a poll outage can be the ENVIRONMENT's fault rather than the handle's, the reaper corroborates before charging crash input: when MORE THAN ONE held handle fails its poll in the same tick, the failure is classified as environmental (a procfs/sysctl-style outage) — no streak grows, one diagnostic names the condition, and every handle stays alive; after 40 consecutive environmental ticks (~10s) that immunity is lifted and per-handle detection resumes with an escalation diagnostic, so a persistently broken pair cannot keep crash detection defeated forever. The corroboration has an honest residual hole, stated here on purpose: a SINGLE held handle has no peers to corroborate against, so a fleet-wide poll outage on a one-instance engine still crash-detects that handle — its recorded cause says the error could not be corroborated (single-handle fleet) and points at the platform's process-table source before blaming the agent. An ADOPTED process's exit records that its exit code is unavailable (it is not this engine's child) instead of asserting a signal termination it cannot prove; and a spawn whose start-time fingerprint cannot be read FAILS closed — the engine never records a start-time-less (pid-only) fingerprint that would silently weaken later adoption decisions (a read that fails because the agent already exited instantly is surfaced as exactly that, not as a platform failure, and the just-spawned process group is killed before the error returns — no orphan). Adoption itself re-evaluates budgets: the ledger survived the crash, so an adopted instance whose committed totals already cross its ceiling fires the breach (record + Breach Action) right at Engine::open — the crash-gap no longer waits for a new usage event that a quiet agent may never send. This closes the NFR-1 guarantee: after an engine crash, a surviving agent is re-adopted and no orphan process is left unsupervised. The guarantee has one ratified operator escape — kt agent start --detach (story 12-1). A detached spawn is committed to the write-ahead record WITH its detach flag, and the handle is held disarmed at spawn time and on every later adoption alike (the flag rides the record, so adoption re-holds it disarmed): the engine's clean exit kills nothing, and the agent survives across N commands until an explicit stop — which still terminates it through the re-held handle. The stop's reach differs by platform and by WHEN the stop happens, stated precisely: on Unix the process group is unchanged, so a stop's SIGTERM/SIGKILL escalation always reaches the whole tree — spawned or adopted; on Windows the whole-tree reach is a SPAWN-TIME-JOB property — the engine that spawned the detached child stops it with TerminateJobObject (descendants included), but an ADOPTED detached handle holds no job (there is no documented way to re-open the spawn-time job from a bare pid), so its stop escalation is TerminateProcess on the direct child only — a detached agent's own descendants are not covered by an adopted Windows stop, the one honest platform asymmetry in the story. The trade is stated on every surface the flag touches — the --help text and a stderr notice at start: between commands there is no crash detection, no budget enforcement, and no event delivery; supervision is command-scoped, and the gap is the price of survival. Detach is refused before any side effect for engine-observed instances: their loopback metering listener lives inside the starting command, so a detached start would strand the agent's base_url on a dead port (--detach on such a manifest exits 5 with the reason and the remediation). Removal upholds the same guarantee from the other direction: removing a live or adopted instance stops its process first — terminating the whole group/job and clearing its write-ahead record — before the row is deleted, so remove never leaves an unsupervised orphan (this holds for both a plain and a --force remove; --force only governs whether a running instance may be removed without an explicit prior stop, not whether the process is torn down).

Pause and resume are honest about what they can guarantee for a given agent on the running OS — "surfaced not silent". The level is read from the instance's persisted Capability Declaration, projected onto the current OS at read time (never re-derived from the manifest, never frozen at registration), and the supervisor dispatches on it three ways. Guaranteed (Unix): the backend delivers SIGSTOP to the whole process group — a real, verifiable suspension (a heartbeat stops) — and SIGCONT on resume; the transition records a plain pause/resume command cause WHEN the engine holds the process handle. With no handle held (a row left running/paused by a prior engine whose process is gone), the recorded cause is instead the honest best-effort qualifier naming the missing handle — a guarantee that signalled nothing never reads as a performed suspension — and a breach-driven cause override (BudgetExceeded) is wrapped in that same qualifier rather than replacing it. The persist-first ordering matches stop: the transition commits before the signal, a transition failure means no signal was sent, and a signal failure after the commit emits a diagnostic naming the ledger/process divergence (with the resume-to-realign or stop recovery) instead of leaving it silent. Best-effort (the Windows default per AD-4, since Windows has no clean guaranteed whole-process suspend from std and no undocumented suspend API is used): the state still transitions, but never silently — a visible qualifier is emitted both in the transition event (a dedicated pause-best-effort / resume-best-effort cause) and, at the CLI, as a note on stderr. Unsupported (including the honest default when pause is simply not declared for the current OS): the command fails fast, quoting the declaration (EngineError::CapabilityUnsupported), with no state change, no process signal, and no fake attempt. resume gets a dedicated variant of that fail-fast (EngineError::ResumeUnsupported) for the drift case — an instance already paused whose CURRENT pause declaration reads unsupported — naming the state and the stop + start recovery instead of stranding the operator with a bare pause-unsupported error. The Windows best-effort path is behavior-verified on the windows-latest CI leg; on Unix hosts it is compile-checked only, exactly like the Windows stop path.

Modules

crates/kt/src/
├── main.rs             # clap command parsing and dispatch: `Commands::Agent` / `Commands::SelfUpdate`
├── cli/
│   ├── mod.rs
│   ├── agent.rs        # `kt agent` subcommand tree — register/start/stop/pause/resume/send/logs/usage/list/show/remove/config/memory
│   └── self_update.rs  # `kt self-update`: binary self-maintenance
├── error.rs            # miette/thiserror diagnostics: the `Agent*` family + `SelfUpdateFailed`
├── exit_code.rs        # the frozen process exit-code table (0–6) and the error → code mapping
├── install_channel.rs  # detects how `kt` was installed (cargo, Homebrew, manual) for self-update
├── ui.rs               # shared terminal colors, icons, statuses, and progress bars
└── update_check.rs     # cached hourly check for a newer GitHub Release

kt is a thin, synchronous frontend over the engine's blocking() facade (AD-13): main.rs parses arguments into exactly two top-level commands — agent (an AgentCommands subtree: register, start, stop, pause, resume, send, logs, usage, list, show, remove, config get/config set, memory attach/memory detach) and self-update — and dispatches. Every agent subcommand handler in cli/agent.rs calls straight into ktesio-engine's public API through that facade; kt never touches the engine's SQLite store or computes a path itself (the engine is the sole path authority — see "Engine modules" above). Results print to stdout; diagnostics, deprecation/update notices, and best-effort qualifiers print to stderr, so --json output is always the only thing on stdout (mirrored end-to-end in crates/kt/tests/agent_cli.rs).

error.rs holds small dedicated diagnostic structs (thiserror + miette::Diagnostic, each carrying one message and its own #[diagnostic(code(...))]) rather than one large enum: the Agent* family (AgentDuplicateName, AgentInvalidName, AgentNotFound, AgentRunningRequiresForce, AgentIo, AgentStore, AgentUnknownKind, AgentManifestNotFound/AgentManifestInvalid/AgentManifestUnreadable for the adapter.toml manifest, AgentNoMeteringSource, AgentNoCapabilities, AgentInvalidTransition, AgentLaunchFailed, AgentCapabilityUnsupported, AgentUnknownConfigKey, AgentConfig) plus SelfUpdateFailed. The AgentManifest* variants describe the current adapter.toml manifest — they are unrelated to the retired skill-manager's own skills.json manifest, which no longer exists in this crate.

kt self-update is deliberately independent of the agent runner: install_channel.rs detects whether the binary was installed via Cargo, Homebrew, or a manual release download, and update_check.rs performs a cached, hourly, opt-out (KTESIO_NO_UPDATE_CHECK=1, or automatically under CI=true) check against the latest GitHub Release so users get a one-line stderr nudge without a network call on every invocation. Both modules predate the agent-runner pivot and are kept as binary-maintenance concerns, not skill management.

Design Choices

  • kt is a thin frontend: every agent command handler delegates to the engine's blocking facade and renders the result; no business rule (lifecycle, config precedence, budget/cost math) is duplicated in the CLI (AD-1).
  • Errors use thiserror inside the engine and are wrapped into miette diagnostics with remediation hints in kt (ADOPTED pattern), so a rejected transition, an unresolved secret, or a validation failure always names the problem and a next step.
  • Output discipline is uniform across every command: results go to stdout, notices and diagnostics go to stderr, so scripting against --json is always safe (FR-26, NFR-5).
  • ui.rs centralizes terminal rendering (colors, icons, table layout, progress) so command handlers stay declarative about what to show, not how to render it.
  • kt agent list / kt agent show are the single canonical way to read the Fleet — there is no separate top-level list/show.

See Also

  • Command reference — every kt agent command, its arguments, and the unified config keys.
  • Adapter manifest — the adapter.toml shape for registering an agent.
  • Testing — required checks, fixtures, and coverage gates.

On this page