Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/PROVIDERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ This capability model is
[ADR-0016](adr/0016-capability-driven-integration-adapters.md) (Accepted); the controls below
implement it. `ak host` owns execution-host lifecycle and selection (`status`, `pick`, `refresh`,
and `off`), with `ak x host` as its plumbing spelling. Inference providers and bindings remain
separate axes even though some provider controls share that workflow.
separate axes even though some provider controls share that workflow. `ak host status` also
checks every binding declared in `kit.json` and prints a warning naming any entry with an
unknown host, unknown provider, or unsupported transport — warnings only; nothing is changed
or removed on your behalf.

---

Expand Down
12 changes: 6 additions & 6 deletions docs/TRANSCRIPTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,15 +175,15 @@ and image-only pastes get the right kind" (the two edges).

## 4. The `readSession` pipeline — how one session becomes a payload

`readSession(id, opts)` (`usage-index.mjs:1350-1438`) is the only way
`readSession(id, opts)` (`usage-index.mjs:1457-1513`) is the only way
transcript content leaves the module, and every step is a gate:

### 4.1 Locate, contain, bound

1. **Id grammar before any filesystem access** — `VALID_ID`
(`/^[A-Za-z0-9._-]{1,128}$/`, `usage-index.mjs:83`) rejects traversal
shapes with `ERR_INVALID_SESSION_ID` (`usage-index.mjs:1382-1386`).
2. **Locate by id** across both roots (`locate`, `usage-index.mjs:1392`),
shapes with `ERR_INVALID_SESSION_ID` (`usage-index.mjs:1399-1406`).
2. **Locate by id** across both roots (`locate`, `usage-index.mjs:1409`),
consulting the scan cache when present but never requiring it —
`readSession` works with no prior `buildIndex`.
3. **Realpath containment** (`usage-index.mjs:1388-1402`) — the resolved file
Expand All @@ -198,8 +198,8 @@ transcript content leaves the module, and every step is a gate:
### 4.2 Parse and price

The file is parsed with `withTurns: true` by the provider's parser
(`usage-index.mjs:1483-1490`), and `meta` is assembled
(`usage-index.mjs:1467-1495`) with the same fields the Sessions view rows
(`usage-index.mjs:1502-1509`), and `meta` is assembled
(`usage-index.mjs:1518-1545`) with the same fields the Sessions view rows
carry — `prompts`, `responses`, `exceptions`, `sidechain`, `threadSource`,
`models`, `tools`, `skill`/`plugin`, worktree — plus a `cost` priced from the
same per-model usage rows `aggregate()` uses (the header used to render a
Expand All @@ -211,7 +211,7 @@ Every turn body is passed through `maskSecrets` (`usage-index.mjs:196` — the
23 secret shapes) **server-side, before
serialization**, then length-capped at `MAX_TURN_CHARS` (40,000,
`usage-index.mjs:77`) with the marker appended
(`usage-index.mjs:1530-1540`). Two invariants:
(`usage-index.mjs:1552-1561`). Two invariants:

- **Presence is the signal.** `truncated`/`originalChars` are emitted only
when the slice fired, so a complete turn cannot be misread as abridged.
Expand Down
4 changes: 2 additions & 2 deletions docs/USAGE-SCORECARD-METRICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,7 @@ session that runs from 23:58 local to 00:05 local is billed to the day its
*first* row landed on (test:
`tests/kit/usage-index.test.mjs:634`, "a session that opens before midnight
is counted on its first billed day"). Accumulation:
`byDay[row.day].cost += rowCost` (`usage-index.mjs:899`). Bar height:
`byDay[row.day].cost += rowCost` (`usage-index.mjs:1020`). Bar height:
`h = maxDay ? max(2, cost/maxDay*100) : 2` (`dashboard/client.mjs`) —
every non-empty day gets a visually nonzero bar (floor of 2%), so a very
cheap day is never rendered as invisible.
Expand Down Expand Up @@ -964,7 +964,7 @@ commit `540be18` on this branch.
Claude's parser passes `responses: 1` per assistant turn
(`usage-index.mjs:556`, the current equivalent), but Codex's call
passed no such field at all. Because `byModel[model].responses` is summed
directly from each usage row's `responses` field (`usage-index.mjs:974`,
directly from each usage row's `responses` field (`usage-index.mjs:1022`,
`m.responses += row.responses`), **every** Codex model in §10's "Models in
Play" list displayed `0 resp` regardless of real token/cost volume or actual
`agent_message` count. **Fix:** `parseCodex` now passes `responses:
Expand Down
5 changes: 5 additions & 0 deletions docs/adr/0015-managed-codex-native-statusline.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

- **Status:** Accepted
- **Date:** 2026-07-28
- **Updated:** 2026-08-14
- **Update note:** The boolean `statuslineSupported` consumer this ADR's context
describes was removed as dead code in the Phase 0 consistency pass (finding
F-07): it had zero call sites. The registry capability `commandStatusline` is
the surviving vocabulary for this distinction.
- **Deciders:** agentic-kit maintainers
- **Related:** [ADR-0001](0001-one-routing-policy-many-projections.md),
[ADR-0006](0006-primary-host-and-ambidextrous-mirroring.md),
Expand Down
11 changes: 10 additions & 1 deletion docs/adr/0016-capability-driven-integration-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,21 @@
- **Status:** Accepted; compatibility clauses superseded by
[ADR-0020](0020-ga-stable-surfaces.md)
- **Date:** 2026-07-28
- **Updated:** 2026-08-04
- **Updated:** 2026-08-14
- **Update note:** Added read-only Codex plugin-hook compatibility facts,
runtime-selected Ruflo project-memory store proofs, and the non-correlatable
OpenRouter account-analytics boundary; removed the pre-GA compatibility command,
persisted fields, and adapter bootstrap. ADR-0023 now requires each host adapter
to declare its setup trust posture and changes for host-neutral preflight.
Phase 0 consistency pass (2026-08-14): host adapters gained a required
`enabledByDefault` boolean and the three enabled-host default literals now
derive from it via `defaultHostMap()` (F-15); the `observability` axis is
recorded as terminal — validation metadata with referential integrity only,
deliberately not a dispatch surface, no collector loop exists (F-12); the
non-throwing `validateBinding` is wired into `ak host status` as per-entry
warnings (F-16); and the integrations migrator derives each host's native
default provider from the provider registry's host-login entries instead of a
literal map, inferring no binding at all for hosts without one (F-13).
- **Deciders:** agentic-kit maintainers
- **Related:** [ADR-0001](0001-one-routing-policy-many-projections.md),
[ADR-0003](0003-auto-seed-dual-host-provenance.md),
Expand Down
18 changes: 16 additions & 2 deletions src/commands/x/host.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import { parseRouteSpecs, formatModelHelp, PRIMARY_HOSTS, DEFAULT_PRIMARY_HOST,
import { loadKitConfig, saveKitConfig } from '../../lib/config.mjs';
import { OPENCODE_LIFECYCLE_ADAPTER, reconcileOpencodeGuidance } from '../../lib/opencode.mjs';
import { runLifecycle } from '../../lib/adapters/lifecycle.mjs';
import { routableHostIds } from '../../lib/adapters/index.mjs';
import {
routableHostIds, defaultHostMap, validateBinding, HOST_REGISTRY, PROVIDER_REGISTRY,
} from '../../lib/adapters/index.mjs';
import {
newlyEnabledHostTrustManifest, trustManifestLines,
} from '../../lib/trust-manifest.mjs';
Expand Down Expand Up @@ -149,6 +151,16 @@ export async function run({ flags, positionals, pkgRoot }) {
return 2;
}

/** Structured, friendly warnings for invalid user-declared bindings in kit.json
* integrations.bindings (F-16: validateBinding had zero call sites — wired
* here so a bad entry surfaces as a warning instead of silent garbage or an
* uncaught TypeError reaching the user). A valid binding list yields []. */
export function bindingWarnings(cfg) {
return (cfg.integrations?.bindings ?? []).flatMap((binding, index) =>
validateBinding(binding, { hosts: HOST_REGISTRY, providers: PROVIDER_REGISTRY }).map((error) =>
`kit.json integrations.bindings[${index}].${error.path.replace(/^binding\./, '')}: ${error.code} (${JSON.stringify(error.value)})`));
}

async function status({ flags, cwd }) {
const cfg = loadKitConfig();
const facts = await collectIntegrationFacts({ cwd, cfg });
Expand All @@ -170,6 +182,8 @@ async function status({ flags, cwd }) {
return 0;
}

for (const message of bindingWarnings(cfg)) warn(message);

const dflt = isDefault(cfg);
console.log(bold('ruflo agent hosts') + dim(` (wiring scope: ${scope})`));
for (const h of HOSTS) {
Expand Down Expand Up @@ -331,7 +345,7 @@ async function off({ cwd, pkgRoot }) {
models: [],
maxBudgetUsd: null,
};
cfg.integrations.hosts = { claude: true, codex: false, opencode: false };
cfg.integrations.hosts = defaultHostMap();
cfg.routing.primaryHost = DEFAULT_PRIMARY_HOST;
cfg.routing.routes = {};
cfg.integrations.ownership ??= {};
Expand Down
4 changes: 4 additions & 0 deletions src/lib/adapters/bindings.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ export function resolveBinding(bindings, {
return candidates.length === 1 ? candidates[0] : null;
}

// Non-throwing counterpart to assertValidBinding — wired into `ak host status`
// (src/commands/x/host.mjs bindingWarnings) so an invalid user-declared
// kit.json binding surfaces as a friendly warning list instead of an uncaught
// TypeError (F-16).
export function validateBinding(binding, registries, constraints = {}) {
const errors = [];
const hostIds = new Set(constraints.hosts ?? (registries?.hosts ?? []).map((entry) => entry.id));
Expand Down
57 changes: 46 additions & 11 deletions src/lib/adapters/config.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,35 @@
import { isDeepStrictEqual } from 'node:util';
import { immutable } from './schema.mjs';
import { PROVIDER_REGISTRY } from './registries.mjs';

export const CURRENT_INTEGRATIONS_VERSION = 2;

// A host's native provider = the registry provider with host-login
// credentials whose projections include that host (anthropic -> claude,
// openai -> codex). Derived from the registry instead of a literal map so
// it can't drift from the adapters it describes, and so a host with no such
// provider (e.g. opencode) simply has none — never a synthetic stand-in
// (F-13). Two host-login providers claiming the same host would silently
// last-write-wins in a plain Map build, defeating the whole point of
// deriving this from the registry — fail loudly at load time instead.
function buildNativeProviderByHost(providers) {
const byHost = new Map();
for (const provider of providers) {
if (provider.credentials?.kind !== 'host-login') continue;
for (const host of provider.projections) {
const existing = byHost.get(host);
if (existing && existing !== provider.id) {
throw new Error(
`adapter registry ambiguity: both '${existing}' and '${provider.id}' claim host-login for host '${host}'`,
);
}
byHost.set(host, provider.id);
}
}
return byHost;
}
const NATIVE_PROVIDER_BY_HOST = buildNativeProviderByHost(PROVIDER_REGISTRY);

const plain = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
const own = (value, key) => plain(value) && Object.hasOwn(value, key);

Expand Down Expand Up @@ -61,22 +88,30 @@ export function migrateIntegrationConfig(config = {}, _options = {}) {
? structuredClone(providers.hosts)
: structuredClone(existing.hosts ?? {});
const enabled = Object.entries(hosts).filter(([, on]) => on).map(([host]) => host);
const defaults = { claude: 'anthropic', codex: 'openai' };
const priorBindings = mergeBindings(
Array.isArray(existing.bindings) ? existing.bindings : [],
Array.isArray(providers.bindings) ? providers.bindings : [],
);
const seenHosts = new Set(priorBindings.map((binding) => binding.host));
const inferred = enabled.filter((host) => !seenHosts.has(host)).map((host) => ({
id: `${defaults[host] ?? 'unknown'}-via-${host}`,
host,
provider: defaults[host] ?? null,
model: null,
transport: 'native',
endpoint: null,
provenance: defaults[host] ? 'inferred' : 'unknown',
managedBy: 'unknown',
}));
// A host with no registered native provider (F-13) gets no inferred
// binding at all — nothing is restamped on any load, and it stays that
// way until a real binding is declared for it.
const inferred = enabled
.filter((host) => !seenHosts.has(host))
.map((host) => {
const provider = NATIVE_PROVIDER_BY_HOST.get(host);
return provider ? {
id: `${provider}-via-${host}`,
host,
provider,
model: null,
transport: 'native',
endpoint: null,
provenance: 'inferred',
managedBy: 'unknown',
} : null;
})
.filter((binding) => binding !== null);
const ownership = structuredClone(existing.ownership ?? {});
const reverseMarker = 'rufloCodexMcp';
const hasLegacyCodex = (own(providers, 'codexMcp') && providers.codexMcp != null)
Expand Down
17 changes: 14 additions & 3 deletions src/lib/adapters/registries.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export function validateHostAdapter(value, {
['detect-never-overwrite', 'managed', 'unmanaged'], 'host.install.externalInstallPolicy');
validateCapabilities(value.capabilities, HOST_CAPABILITIES, 'host.capabilities');
validateHostTrust(value.trust);
if (typeof value.enabledByDefault !== 'boolean') throw new TypeError('host.enabledByDefault must be boolean');
assertId(value.configProjection, 'host.configProjection');
assertStringArray(value.observability, 'host.observability');
if (projections && !projections[value.configProjection]) {
Expand Down Expand Up @@ -153,7 +154,7 @@ const OBSERVABILITY_MAP = registryFrom([

const hostEntries = [
{
id: 'claude', label: 'Claude Code',
id: 'claude', label: 'Claude Code', enabledByDefault: true,
install: { bin: 'claude', npmPackage: '@anthropic-ai/claude-code', externalInstallPolicy: 'detect-never-overwrite' },
capabilities: { canDriveSession: true, canBePrimary: true, canRouteActivities: true, commandStatusline: true, transcripts: true, usage: true, nativeMcpConfig: true, nativeGuidance: true },
auth: { apiKeyEnv: ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'], loginFile: ['.claude', '.credentials.json'], keyOverridesLogin: false },
Expand All @@ -178,7 +179,7 @@ const hostEntries = [
configProjection: 'claude', observability: ['claude-transcripts', 'claude-statusline'],
},
{
id: 'codex', label: 'OpenAI Codex',
id: 'codex', label: 'OpenAI Codex', enabledByDefault: false,
install: { bin: 'codex', npmPackage: '@openai/codex', externalInstallPolicy: 'detect-never-overwrite' },
capabilities: { canDriveSession: true, canBePrimary: true, canRouteActivities: true, commandStatusline: false, transcripts: true, usage: true, nativeMcpConfig: true, nativeGuidance: true },
auth: { apiKeyEnv: ['OPENAI_API_KEY'], loginFile: ['.codex', 'auth.json'], keyOverridesLogin: true },
Expand All @@ -199,7 +200,7 @@ const hostEntries = [
configProjection: 'codex', observability: ['codex-transcripts', 'codex-app-server'],
},
{
id: 'opencode', label: 'OpenCode',
id: 'opencode', label: 'OpenCode', enabledByDefault: false,
install: { bin: 'opencode', npmPackage: 'opencode-ai', externalInstallPolicy: 'detect-never-overwrite' },
capabilities: { canDriveSession: true, canBePrimary: false, canRouteActivities: true, commandStatusline: false, transcripts: true, usage: false, nativeMcpConfig: true, nativeGuidance: true },
auth: { apiKeyEnv: [], loginFile: ['.local', 'share', 'opencode', 'auth.json'], keyOverridesLogin: false },
Expand Down Expand Up @@ -247,6 +248,9 @@ const PROVIDER_MAP = registryFrom(providerEntries,
(entry) => validateProviderAdapter(entry, { projections: PROJECTION_MAP, observability: OBSERVABILITY_MAP }), 'provider');

export const PROJECTION_REGISTRY = immutable(Object.values(PROJECTION_MAP));
// Validation metadata + referential integrity only (host.observability entries
// are cross-checked against this set) — deliberately NOT a dispatch surface;
// no collector loop maps an observability id to a live collector (F-12).
export const OBSERVABILITY_REGISTRY = immutable(Object.values(OBSERVABILITY_MAP));
export const HOST_REGISTRY = immutable(Object.values(HOST_MAP));
export const PROVIDER_REGISTRY = immutable(Object.values(PROVIDER_MAP));
Expand Down Expand Up @@ -274,6 +278,13 @@ export const providerIds = (predicate = () => true) => PROVIDER_REGISTRY.filter(
export const primaryHostIds = () => hostIds((host) => host.capabilities.canBePrimary);
export const routableHostIds = () => hostIds((host) => host.capabilities.canRouteActivities);
export const managedHostIds = () => hostIds((host) => host.capabilities.canDriveSession);
/** Fresh {hostId: enabledByDefault} for every session-driving host — the single
* source for the `{claude:true, codex:false, opencode:false}` literal that used
* to be triplicated across config.mjs, providers.mjs and x/host.mjs (F-15).
* Returns a new object per call since every call site mutates its copy. */
export const defaultHostMap = () => Object.fromEntries(
HOST_REGISTRY.filter((host) => host.capabilities.canDriveSession).map((host) => [host.id, host.enabledByDefault]),
);
export function hostsWithCapability(entriesOrCapability, maybeCapability) {
const entries = Array.isArray(entriesOrCapability) ? entriesOrCapability : HOST_REGISTRY;
const capability = Array.isArray(entriesOrCapability) ? maybeCapability : entriesOrCapability;
Expand Down
3 changes: 2 additions & 1 deletion src/lib/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
CURRENT_INTEGRATIONS_VERSION,
migrateIntegrationConfig,
} from './adapters/config.mjs';
import { defaultHostMap } from './adapters/registries.mjs';
import {
DEFAULT_PRIMARY_HOST,
ROUTING_SCHEMA_VERSION,
Expand All @@ -25,7 +26,7 @@ const DEFAULTS = {
mcp: { register: true, excludeFamilies: [] },
integrations: {
version: CURRENT_INTEGRATIONS_VERSION,
hosts: { claude: true, codex: false, opencode: false },
hosts: defaultHostMap(),
bindings: [],
},
routing: {
Expand Down
56 changes: 56 additions & 0 deletions src/lib/execution/subprocess.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,62 @@ export function createJsonlSummaryCapture(select, label) {
};
}

/** Retain only the final non-empty plain-text line, bounded the same way as
* the JSONL capture above. For hosts whose oneshot mode emits plain text on
* stdout instead of JSONL (Hermes-class hosts): there is no structured event
* to select a field from, so the summary is simply the last non-empty line
* seen — an oversized line is discarded without poisoning a later one. */
export function createPlainTextSummaryCapture(label) {
if (typeof label !== 'string' || !label) throw new TypeError('plain-text summary capture requires a label');
let line = '';
let discarding = false;
let selected = null;
let selectedTooLarge = false;

const consume = (value) => {
if (!value.trim()) return;
if (Buffer.byteLength(value, 'utf8') > SUMMARY_LINE_LIMIT) {
selectedTooLarge = true;
selected = null;
return;
}
selected = value;
};

return {
write(chunk) {
let remaining = String(chunk);
while (remaining) {
const newline = remaining.indexOf('\n');
const fragment = newline === -1 ? remaining : remaining.slice(0, newline);
remaining = newline === -1 ? '' : remaining.slice(newline + 1);
if (!discarding) {
const next = `${line}${fragment}`;
if (Buffer.byteLength(next, 'utf8') > SUMMARY_LINE_LIMIT) {
line = '';
discarding = true;
} else {
line = next;
}
}
if (newline !== -1) {
if (!discarding) consume(line.replace(/\r$/, ''));
line = '';
discarding = false;
}
}
},
read() {
if (!discarding && line) {
consume(line.replace(/\r$/, ''));
line = '';
}
if (selectedTooLarge) throw new TypeError(`${label} final output exceeded the ${SUMMARY_LINE_LIMIT}-byte cap`);
return selected;
},
};
}

function waitForChild(child, stdout, stderr) {
return new Promise((resolve) => {
let settled = false;
Expand Down
Loading
Loading