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
12 changes: 10 additions & 2 deletions docs/HOST-SUPPORT.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
# Host support: Claude Code, Codex, and OpenCode

This is the canonical compatibility reference for the three execution hosts that
agentic-kit can manage. It compares the host itself, Ruflo, agentic-qe (AQE), and
This is the canonical compatibility reference for agentic-kit's three **built-in**
execution hosts. It compares the host itself, Ruflo, agentic-qe (AQE), and
RuvNet Brain without treating those independent layers as interchangeable.

Behind an experimental flag, agentic-kit can also admit **external host adapters**
that extend this set with a host not shipped in-tree — see
[External host adapters](PROVIDERS.md#external-host-adapters-experimental) and
[ADR-0029](adr/0029-host-adapter-extension-point.md). An admitted external host
picks up the same capability-driven treatment described here, but it is not one
of the three built-ins this reference compares, and it can never claim
primary-host, AQE-provider, or status-line status.

Evidence cutoff: **2026-08-04**. The comparison was checked against agentic-kit
`4.0.0-alpha.36`, Ruflo `3.34.0`, agentic-qe `3.13.x`, RuvNet Brain `4.0.7`,
Claude Code `2.1.222`, Codex CLI `0.146.0`, and OpenCode `1.18.x`. Host and
Expand Down
2 changes: 1 addition & 1 deletion docs/ddd/component-directory.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ lintable where mechanical):
### Iconography

Official marks are used only where the dashboard already ships them as official — the three
host SVGs Observability renders on session rows — and are reused byte-identically so a
built-in host SVGs Observability renders on session rows — and are reused byte-identically so a
component looks the same everywhere. Every other component gets a **monogram tile**: its
initial(s) on a rounded tile in its category hue. A monogram is an honest "no official mark"
statement, not a stand-in logo; if an upstream later publishes a usable mark, swapping it in is
Expand Down
5 changes: 3 additions & 2 deletions docs/ddd/ubiquitous-language.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,9 @@ runtime state is a chip word, never a prose word. See

## Usage rules

- Say **host** when referring to Claude Code, Codex, OpenCode, session drivers, leadership, or
activity routing.
- Say **host** for any session driver or activity-routing target the registry recognizes —
Claude Code, Codex, and OpenCode are the built-in examples, not the exhaustive list — and for
leadership.
- Say **inference provider** when referring to Anthropic, OpenAI, OpenRouter, Ollama, billing,
provider credentials, or inference endpoints.
- Qualify **projection** as configuration projection or read-model projection when ambiguity is
Expand Down
6 changes: 4 additions & 2 deletions src/commands/x/host.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { loadKitConfig, saveKitConfig } from '../../lib/config.mjs';
import { reconcileOpencodeGuidance } from '../../lib/opencode.mjs';
import { runLifecycle } from '../../lib/adapters/lifecycle.mjs';
import { lifecycleAdapterFor } from '../../lib/adapters/lifecycle-registry.mjs';
import { hostTierLabel, hostAsymmetryNote } from '../../lib/hosts.mjs';
import {
routableHostIds, defaultHostMap, validateBinding, HOST_REGISTRY, PROVIDER_REGISTRY,
} from '../../lib/adapters/index.mjs';
Expand Down Expand Up @@ -195,12 +196,13 @@ async function status({ flags, cwd }) {
: dflt ? 'enabled (default — ruflo default-on, no env written)'
: d.wired ? 'enabled, wired'
: 'enabled, not wired → ak sync';
const tier = h.id === 'opencode' ? dim(' · routing host (ak run; never primary/AQE)')
: dim(' · routing host');
const tier = dim(` · ${hostTierLabel(h.id)}`);
// auth/billing axis — subscription ($0) vs metered key, per host.
const auth = d.present ? hostAuthState(h.id, { present: true }) : null;
const authStr = auth ? dim(` ${auth.mode}/${auth.billing === 'subscription' ? '$0' : auth.billing}`) : '';
console.log(` ${h.id.padEnd(9)} ${(d.version ? `v${d.version}` : '—').padEnd(12)} ${state}${authStr}${tier}`);
const note = hostAsymmetryNote(h.id);
if (note) console.log(` ${dim(note)}`);
}

// agentic-qe LLM provider (AQE_LLM_PROVIDER) + fallback chain
Expand Down
74 changes: 73 additions & 1 deletion src/lib/hosts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
// ~/.codex/auth.json (key overrides login). claude auth on macOS lives in the
// Keychain (no readable file); ANTHROPIC_API_KEY, when used, is not a simple
// override of a subscription login, so we label it conservatively.
import { HOST_REGISTRY } from './adapters/index.mjs';
import { HOST_REGISTRY, effectiveHostRegistry } from './adapters/index.mjs';

/** Per-host adapter descriptors. Logical names (`guidanceFile`, `loginFile`
* segments) are resolved to real paths by callers so this stays pure. */
Expand Down Expand Up @@ -69,3 +69,75 @@ export function drivingHost(env = process.env, cfg = null) {
if (primary && primaryCapable) return /** @type {'claude'|'codex'} */ (primary);
return 'claude';
}

/**
* Human phrase for a host's tier, derived purely from its capabilities — never
* from `host.id` (the anti-pattern this replaces: x/host.mjs's status() used
* to special-case `h.id === 'opencode'` for its tier text; D-2/F-25/F-26).
* Accepts a host id (resolved against `registry`, default
* effectiveHostRegistry() so an admitted external host resolves too) or a raw
* host-entry object directly (for a synthetic host not registered anywhere).
* TRUE by construction for any capability combination validateHostAdapter
* accepts, including a future built-in or admitted external adapter — the
* label follows the flags, not a name.
*
* @param {string|object} hostIdOrEntry
* @param {{ registry?: ReadonlyArray<any>, builtins?: ReadonlyArray<any> }} [opts]
* @returns {string} '' when the host isn't found or can't drive a session.
*/
export function hostTierLabel(hostIdOrEntry, { registry = effectiveHostRegistry(), builtins = HOST_REGISTRY } = {}) {
const host = typeof hostIdOrEntry === 'string'
? registry.find((entry) => entry.id === hostIdOrEntry)
: hostIdOrEntry;
if (!host?.capabilities?.canDriveSession) return '';
const { canBePrimary, canRouteActivities } = host.capabilities;

if (canBePrimary) return 'drives sessions · can lead';
if (!canRouteActivities) return 'drives sessions';

const isBuiltin = builtins.some((entry) => entry.id === host.id);
const base = isBuiltin ? 'routing only · supervised' : 'routing only · external adapter';
return host.legacy?.aqeProvider ? base : `${base} · not AQE`;
}

/**
* A one-line, capability/trust-derived note about a host's asymmetric
* behavior versus the other managed hosts — cross-host MCP delegation (F-25)
* and the ruflo backend env flag / permission consent boundary (F-26) —
* assembled only from facts that are true FOR THIS HOST's own registry
* entry, never from an id check. '' when nothing asymmetric applies.
*
* @param {string|object} hostIdOrEntry
* @param {{ registry?: ReadonlyArray<any> }} [opts]
* @returns {string}
*/
export function hostAsymmetryNote(hostIdOrEntry, { registry = effectiveHostRegistry() } = {}) {
const host = typeof hostIdOrEntry === 'string'
? registry.find((entry) => entry.id === hostIdOrEntry)
: hostIdOrEntry;
if (!host?.capabilities?.canDriveSession) return '';
const notes = [];

// F-25: this host is registered as callable FROM another host via MCP — a
// real bridge capability, read off the trust manifest rather than an id
// check ('expose <label> to <other> as mcp__x__x' is the manifest's own
// wording for that grant, e.g. codex's claude-to-codex-mcp change).
const bridge = host.trust?.changes?.find((change) =>
change.kind === 'mcp-registration' && /^expose /i.test(change.effect));
if (bridge) notes.push(bridge.effect);

// ADR-0019's "supervised-host contract": a host that drives sessions and
// routes activities but can never be primary is exactly the shape that
// implements a permission consent boundary today (OpenCode's
// permission_required abort) — never auto-approved.
if (!host.capabilities.canBePrimary && host.capabilities.canRouteActivities) {
notes.push('consent boundary — a run can block on a permission event (never auto-approved)');
}

// F-26: ak's ruflo backend env flag (ENABLE_CLAUDE_CODE/ENABLE_CODEX) is
// only wired for hosts whose registry entry declares one; silence used to
// read as "nothing to say" rather than "no flag exists for this host".
if (!host.legacy?.enableEnv) notes.push('no ruflo backend env flag');

return notes.join('; ');
}
18 changes: 18 additions & 0 deletions tests/kit/host-cli-migration.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,24 @@ test('ak host status dispatches the existing read-only status semantics', () =>
'host management retains the existing host/provider fact payload during migration');
});

// D-2/F-25/F-26: the tier text and asymmetry sub-notes are capability-derived
// (src/lib/hosts.mjs's hostTierLabel/hostAsymmetryNote), not an
// `h.id === 'opencode'` special case in x/host.mjs — this pins the actual
// rendered CLI text so a regression back to a hardcoded id check would fail
// here even though nothing depends on the exact strings elsewhere.
test('ak host status renders capability-derived tier text and asymmetry notes for every built-in host', () => {
const sb = sandbox();
const result = ak(sb, 'host', 'status');
assert.equal(result.status, 0, output(result));
const text = result.stdout;
assert.match(text, /^ {2}claude {4}.*· drives sessions · can lead$/m);
assert.match(text, /^ {2}codex {5}.*· drives sessions · can lead$/m);
assert.match(text, /^ {4}expose Codex to Claude Code as mcp__codex__codex in this project$/m);
assert.match(text, /^ {2}opencode {2}.*· routing only · supervised · not AQE$/m);
assert.match(text, /^ {4}consent boundary — a run can block on a permission event \(never auto-approved\); no ruflo backend env flag$/m);
assert.doesNotMatch(text, /routing host/, 'old hardcoded tier text must be fully replaced');
});

test('ak host preserves pick option parsing without performing an interactive run', () => {
const sb = sandbox();
const result = ak(sb, 'host', 'pick', '--help');
Expand Down
67 changes: 66 additions & 1 deletion tests/kit/hosts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { HOST_IDS, adapterFor, drivingHost } from '../../src/lib/hosts.mjs';
import { HOST_IDS, adapterFor, drivingHost, hostTierLabel, hostAsymmetryNote } from '../../src/lib/hosts.mjs';
import { hostAuthState } from '../../src/lib/providers.mjs';
import { managedHostIds } from '../../src/lib/adapters/registries.mjs';

Expand Down Expand Up @@ -104,3 +104,68 @@ test('hostAuthState returns unknown for an unrecognized host', () => {
const a = hostAuthState('zz-not-a-registered-host', { env: {}, present: true });
assert.equal(a.mode, 'unknown');
});

// ── hostTierLabel / hostAsymmetryNote (D-2, F-25/F-26) ───────────────────────
// Pins for the three built-in hosts — the derivation must reproduce (or
// deliberately improve on, per the case-by-case call below) what x/host.mjs's
// status() prints today. Nothing in the suite pinned the OLD literal text
// ('· routing host', '· routing host (ak run; never primary/AQE)') before
// this change, so these values are the new baseline going forward.
test('hostTierLabel: claude (canBePrimary) reads "drives sessions · can lead"', () => {
assert.equal(hostTierLabel('claude'), 'drives sessions · can lead');
});

test('hostTierLabel: codex (canBePrimary) reads "drives sessions · can lead"', () => {
assert.equal(hostTierLabel('codex'), 'drives sessions · can lead');
});

test('hostTierLabel: opencode (routing-only, built-in, no aqeProvider) reads "routing only · supervised · not AQE"', () => {
assert.equal(hostTierLabel('opencode'), 'routing only · supervised · not AQE');
});

test('hostTierLabel returns empty for an unknown host id', () => {
assert.equal(hostTierLabel('zz-not-a-registered-host'), '');
});

test('hostTierLabel: a synthetic canDriveSession-only host (no primary, no routing) reads "drives sessions"', () => {
const synthetic = {
id: 'synth-drive-only',
capabilities: { canDriveSession: true, canBePrimary: false, canRouteActivities: false },
legacy: {},
};
assert.equal(hostTierLabel(synthetic), 'drives sessions');
});

test('hostTierLabel: a synthetic non-built-in routing-only host reads "routing only · external adapter · not AQE"', () => {
const synthetic = {
id: 'synth-external',
capabilities: { canDriveSession: true, canBePrimary: false, canRouteActivities: true },
legacy: {},
trust: { changes: [] },
};
// Not in HOST_REGISTRY (the default `builtins`), so it derives as external —
// proving the tier follows capabilities + registry membership, not an id.
assert.equal(hostTierLabel(synthetic), 'routing only · external adapter · not AQE');
});

test('hostTierLabel: a host with no session-driving capability at all yields no tier', () => {
assert.equal(hostTierLabel({ id: 'nd', capabilities: { canDriveSession: false } }), '');
});

test('hostAsymmetryNote: claude has nothing asymmetric to state', () => {
assert.equal(hostAsymmetryNote('claude'), '');
});

test('hostAsymmetryNote: codex states the MCP bridge grant, read off its own trust manifest', () => {
assert.equal(
hostAsymmetryNote('codex'),
'expose Codex to Claude Code as mcp__codex__codex in this project',
);
});

test('hostAsymmetryNote: opencode states its consent boundary and the absent ruflo backend flag', () => {
assert.equal(
hostAsymmetryNote('opencode'),
'consent boundary — a run can block on a permission event (never auto-approved); no ruflo backend env flag',
);
});
Loading