Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/zeron/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ fn harness_from_env() -> zeron_engine::HarnessId {
Ok("grok") => zeron_engine::HarnessId::Grok,
Ok("hermes") => zeron_engine::HarnessId::Hermes,
Ok("pi") => zeron_engine::HarnessId::Pi,
Ok("cline") => zeron_engine::HarnessId::Cline,
_ => zeron_engine::HarnessId::ClaudeCode,
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/agent_accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1446,6 +1446,7 @@ fn harness_slug(harness: HarnessId) -> &'static str {
HarnessId::Hermes => "hermes",
HarnessId::Pi => "pi",
HarnessId::Opencode => "opencode",
HarnessId::Cline => "cline",
HarnessId::Mock => "mock",
}
}
Expand Down
26 changes: 25 additions & 1 deletion crates/engine/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,29 @@ pub fn default_registry() -> HarnessRegistry {
Box::new(|| zeron_harness::OpencodeHarness::new().installed()),
Box::new(|| Ok(Arc::new(zeron_harness::OpencodeHarness::new()) as Arc<dyn Harness>)),
);
// Cline over its native ACP server (`cline --acp`), same lazy pattern:
// the static descriptor mirrors AcpHarness::cline() exactly. No
// `_session/steering` extension documented — turn-boundary steering; the
// effort ladder mirrors the CLI's --thinking levels.
registry.register_lazy(
HarnessDescriptor {
id: HarnessId::Cline,
name: "Cline".into(),
supports_steering: true,
steering_mode: SteeringMode::TurnBoundary,
reasoning_levels: vec![
ReasoningLevel::Minimal,
ReasoningLevel::Low,
ReasoningLevel::Medium,
ReasoningLevel::High,
ReasoningLevel::XHigh,
],
installed: true,
enabled: None,
},
Box::new(|| zeron_harness::AcpHarness::cline().installed()),
Box::new(|| Ok(Arc::new(zeron_harness::AcpHarness::cline()) as Arc<dyn Harness>)),
);
registry
}

Expand Down Expand Up @@ -577,7 +600,8 @@ mod tests {
HarnessId::Grok,
HarnessId::Hermes,
HarnessId::Pi,
HarnessId::Opencode
HarnessId::Opencode,
HarnessId::Cline
]
);
assert!(registry.resolve(HarnessId::Mock).is_ok());
Expand Down
94 changes: 94 additions & 0 deletions crates/harness/src/acp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,94 @@ fn pi_spec() -> AcpAgentSpec {
}
}

fn cline_install_paths() -> Vec<PathBuf> {
npm_global_bins("cline")
}

/// Cline's `--thinking` values (none|low|medium|high|xhigh). Preference-ordered
/// per level; the first value the agent actually advertises wins, and a
/// missing `thought_level` config option degrades to the agent default.
fn cline_effort_values(
reasoning: Option<ReasoningLevel>,
_model: Option<&str>,
) -> Vec<&'static str> {
let Some(level) = reasoning else {
return Vec::new();
};
match level {
ReasoningLevel::Minimal => vec!["none", "low"],
ReasoningLevel::Low => vec!["low", "none"],
ReasoningLevel::Medium => vec!["medium"],
ReasoningLevel::High => vec!["high"],
ReasoningLevel::XHigh => vec!["xhigh", "x-high", "high"],
ReasoningLevel::Max
| ReasoningLevel::Ultra
| ReasoningLevel::Ultracode
| ReasoningLevel::Ultrathink => vec!["xhigh", "high"],
}
}

fn cline_spec() -> AcpAgentSpec {
AcpAgentSpec {
id: HarnessId::Cline,
display_name: "Cline",
executable: "cline",
env_override: "CLINE_EXECUTABLE",
args: &["--acp"],
// cline IS the npm package (`npm i -g cline` — platform binaries land
// on PATH): no separate managed adapter, so resolution is PATH + the
// npm/version-manager dirs only.
npm_package: None,
extra_paths: cline_install_paths,
cli_executable: "cline",
cli_extra_paths: cline_install_paths,
install_hint: "cline (searched PATH, the login shell's PATH, npm global bins, \
/opt/homebrew/bin, /usr/local/bin, and fnm/nvm/volta/pnpm/bun install dirs; \
install with `npm install -g cline`; set CLINE_EXECUTABLE to override)",
// ACP model discovery is the source of truth (Cline advertises the
// active provider's catalog and provider switching); this pass-through
// answers when nothing is advertised. Unknown ids are skipped by the
// config-option set.
models: || {
vec![Model {
id: "default".into(),
label: "Cline default".into(),
description: Some("Runs the model configured in Cline (`cline auth`)".into()),
reasoning_levels: vec![
ReasoningLevel::Minimal,
ReasoningLevel::Low,
ReasoningLevel::Medium,
ReasoningLevel::High,
ReasoningLevel::XHigh,
],
options: Vec::new(),
}]
},
// No `_session/steering` extension documented: steers deliver at turn
// boundaries.
steering_mode: SteeringMode::TurnBoundary,
// Cline's --thinking ladder (none|low|medium|high|xhigh, default
// medium); delivered via the `thought_level` config option when the
// agent advertises one.
reasoning_levels: &[
ReasoningLevel::Minimal,
ReasoningLevel::Low,
ReasoningLevel::Medium,
ReasoningLevel::High,
ReasoningLevel::XHigh,
],
prompt_transform: identity_transform,
effort_values: cline_effort_values,
ladder_extras: &[],
// No prompt-complete extension observed in the ACP docs; the prompt
// response settles the turn and the engine's quiesce watchdogs stay.
prompt_complete_extension: false,
// The binary is a Node-runtime bundle — a cold start can take seconds.
prompt_stall: Some(Duration::from_secs(30)),
stall_hint: "The agent process is likely wedged.",
}
}

/// Background-install managed npm adapters for agents whose CLI is present
/// on this device, so a first chat never pays (or trips over) an npm run.
/// Skips agents whose adapter is already resolvable; failures are logged and
Expand Down Expand Up @@ -492,6 +580,12 @@ impl AcpHarness {
Self::with_spec(pi_spec())
}

/// Cline (`cline --acp`) — Cline Bot's open-source coding agent speaking
/// ACP natively.
pub fn cline() -> Self {
Self::with_spec(cline_spec())
}

/// Use a fixed agent binary instead of PATH/known-location resolution.
pub fn with_executable(mut self, path: impl Into<PathBuf>) -> Self {
self.executable = Some(path.into());
Expand Down
28 changes: 28 additions & 0 deletions crates/harness/tests/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ fn harness() -> AcpHarness {
AcpHarness::grok().with_executable(fixture_path())
}

fn cline_harness() -> AcpHarness {
AcpHarness::cline().with_executable(fixture_path())
}

fn request(prompt: &str) -> RunRequest {
RunRequest {
prompt: prompt.into(),
Expand Down Expand Up @@ -210,6 +214,30 @@ async fn happy_path_maps_chunks_tools_diffs_plans_and_commands() {
assert_eq!(dones(&events), vec![(DoneStatus::Completed, None)]);
}

#[tokio::test]
async fn cline_spec_runs_the_same_fixture_turn() {
let (controls, _steer, _token) = controls();
let mut req = request("scenario:happy");
req.model = None;
let events = run_to_end(&cline_harness(), req, controls).await;
// SessionStarted carries the CLINE harness id from session/new.
assert!(
events.iter().any(|e| matches!(
e,
AgentEvent::SessionStarted { harness, session_id, cwd, .. }
if *harness == HarnessId::Cline && session_id == "s-1" && cwd == "/tmp"
)),
"{events:?}"
);
assert!(
events.contains(&AgentEvent::TextDelta {
text: "Hello".into()
}),
"{events:?}"
);
assert_eq!(dones(&events), vec![(DoneStatus::Completed, None)]);
}

#[tokio::test]
async fn config_options_apply_requested_model_and_effort() {
let (controls, _steer, _token) = controls();
Expand Down
3 changes: 3 additions & 0 deletions crates/proto/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ pub enum HarnessId {
/// protocol (`opencode serve` — the same wire the opencode desktop app
/// speaks).
Opencode,
/// Cline's open-source coding agent, driven over its native ACP server
/// (`cline --acp` — the same wire Zed/Neovim drive).
Cline,
/// Test harness; never shown in production pickers.
Mock,
}
Expand Down
4 changes: 4 additions & 0 deletions crates/ui/assets/icons/cline-mark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions crates/ui/src/icons.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ icon_assets![
(HERMES_MARK, "hermes-mark"),
(PI_MARK, "pi-mark"),
(OPENCODE_MARK, "opencode-mark"),
(CLINE_MARK, "cline-mark"),
];

/// The Claude mark's brand orange (`#D97757`) — zeron keeps it even on the
Expand Down
2 changes: 2 additions & 0 deletions crates/ui/src/pickers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3796,6 +3796,8 @@ pub(crate) fn harness_brand_icon(harness: HarnessId) -> (&'static str, Option<gp
HarnessId::Pi => (crate::icons::PI_MARK, None),
// The pixel-"o" from opencode's wordmark (their favicon), monochrome.
HarnessId::Opencode => (crate::icons::OPENCODE_MARK, None),
// Abstract monochrome C-with-cursor mark, tinted by the surface.
HarnessId::Cline => (crate::icons::CLINE_MARK, None),
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/ui/src/settings/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1218,6 +1218,7 @@ impl Render for AccountsPage {
HarnessId::Hermes => (crate::icons::HERMES_MARK, None),
HarnessId::Pi => (crate::icons::PI_MARK, None),
HarnessId::Opencode => (crate::icons::OPENCODE_MARK, None),
HarnessId::Cline => (crate::icons::CLINE_MARK, None),
_ => (
crate::icons::CLAUDE_MARK,
Some(crate::icons::claude_brand()),
Expand Down
4 changes: 4 additions & 0 deletions crates/ui/src/settings/harnesses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ pub fn blurb(harness: HarnessId) -> &'static str {
HarnessId::Hermes => "Nous Research's Hermes Agent (hermes CLI).",
HarnessId::Pi => "The pi coding agent (pi CLI).",
HarnessId::Opencode => "SST's opencode agent (opencode CLI).",
HarnessId::Cline => {
"Cline's open-source coding agent (cline CLI) — plan/act modes, MCP, checkpoints."
}
HarnessId::Mock => "Scripted test harness.",
}
}
Expand All @@ -59,6 +62,7 @@ pub fn cli_name(harness: HarnessId) -> &'static str {
HarnessId::Hermes => "hermes",
HarnessId::Pi => "pi",
HarnessId::Opencode => "opencode",
HarnessId::Cline => "cline",
HarnessId::Mock => "mock",
}
}
Expand Down
64 changes: 64 additions & 0 deletions docs/research/cline-harness-tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Cline harness: implementation tasks

Companion to [`cline-harness.md`](./cline-harness.md) (feasibility analysis, 2026-08).
Path chosen: **`AcpAgentSpec` over `cline --acp`** — the same shared-`AcpHarness` shape as
Grok/Hermes/Pi. Tasks are ordered; each is independently compilable. Check off as landed.

## T1 — proto: `HarnessId::Cline` variant
- [x] Add `Cline` to `pub enum HarnessId` (`crates/proto/src/agent.rs`), serde kebab-case →
`"cline"` (additive, wire-compatible).
- [x] Grep-verify every `HarnessId` match site compiles (compiler forces exhaustive
matches; new arms mirror Hermes/Pi exactly).
- **Done when**: `cargo check -p zeron-proto -p zeron-harness` passes with only the new
arms added.

## T2 — harness: `cline_spec()` in `crates/harness/src/acp/mod.rs`
- [x] `AcpAgentSpec` mirroring `grok_spec()` / `hermes_spec()`:
executable `cline`, args `["--acp"]`, env override `CLINE_EXECUTABLE`,
npm package pinned (`cline@<minor>`), extra paths = npm-global + homebrew +
version-manager bins, install hint → `npm install -g cline`.
- [x] `models`: static catalog fallback (Cline-curated Anthropic/OpenAI/Gemini/OpenRouter
flagships), reasoning ladder `Minimal|Low|Medium|High|XHigh` via the
`--thinking` mapping (`none`→Minimal).
- [x] `steering_mode: TurnBoundary` (no `_session/steering` documented);
`prompt_complete_extension: false` until a live probe proves otherwise;
`prompt_stall` ~30s.
- [x] `AcpHarness::cline()` constructor + `installed()` probing (PATH, login-shell PATH,
npm/fnm/nvm/volta/pnpm/bun dirs — reuse the existing helpers).
- **Done when**: `cargo test -p zeron-harness acp` passes.

## T3 — harness: fixture tests
- [x] Extend `crates/harness/tests/acp.rs` with a cline spec case pointed at the existing
fake-ACP shell fixture (`CLINE_EXECUTABLE` override): handshake, session/new, prompt
settle, permission request bridge, config-option delivery.
- **Done when**: `cargo test -p zeron-harness` is green.

## T4 — engine: registry + accounts
- [x] `register_lazy` descriptor in `crates/engine/src/registry.rs` (name "Cline",
turn-boundary steering, the T2 ladder, `installed` from the probe).
- [x] `"cline"` CLI-name mapping in `crates/engine/src/agent_accounts.rs`
(grep `HarnessId::Hermes =>` for the full surface).
- **Done when**: `cargo test -p zeron-engine registry` passes.

## T5 — app wiring: `apps/zeron/src/main.rs`
- [x] `Ok("cline") => HarnessId::Cline` in the harness-name parser.
- **Done when**: `cargo check -p zeron` passes.

## T6 — UI surfaces
- [x] Harness description in `crates/ui/src/settings/harnesses.rs` ("Cline — open-source
coding agent (`cline` CLI); plan/act modes, MCP, checkpoints. Install: `npm i -g cline`.").
- [x] Icon/mark + tint in `crates/ui/src/pickers.rs` and
`crates/ui/src/settings/accounts.rs` — monochrome mark tinted by the surface
(house pattern for harnesses without a strong brand color).
- **Done when**: `cargo check -p zeron-ui` passes; picker renders Cline when installed.

## T7 — docs + verification trail
- [x] Record live-probe results (turn settle, config options, `session/load`) in
`cline-harness.md` once a real `cline` CLI is exercised against the fixture/spec.
- [x] Live end-to-end pass against the real CLI: session/new → session/prompt (stopReason end_turn) → cross-process session/load + follow-up turn. Full `scripts/e2e-smoke.sh` run deferred to pre-PR CI.

## Out of scope (deliberate)
- Sandbox ladder mapping (no Cline equivalent — restricted levels fall back to Cline's
permission gating, same accepted delta as Cursor).
- Token-usage display (excluded project-wide).
- `--json` NDJSON and desktop file-IPC approval surfaces (rejected alternatives).
Loading