feat(agents): pont Codex inter-agents + readiness/heartbeat lot 1
Deux chantiers livrés au vert (workspace entier : domain+application+
infrastructure 42 + app-tauri --lib 128, 0 échec).
## Codex inter-agents
- domaine: McpConfigStrategy::TomlConfigHome { target, home_env } +
toml_config_home(...); AgentProfile::materializes_idea_bridge()
(whitelist Claude/ConfigFile + Codex/TomlConfigHome); McpServerWiring
+ encodeur TOML.
- application: lifecycle apply_mcp_config bras TomlConfigHome (écrit
{runDir}/<target>, pousse (home_env, parent) dans spec.env);
guard_mcp_bridge_supported ré-exprimée via materializes_idea_bridge();
catalogue Codex porte toml_config_home(".codex/config.toml","CODEX_HOME").
- app-tauri: is_codex_mcp_profile, migrate_codex_run_dir,
mcp_server_entry_toml.
- tests: matrice domaine TomlConfigHome + round-trip dual Claude/Codex
sur loopback réel (fakes, zéro token).
## Readiness/heartbeat lot 1
- domaine: readiness.rs — ReadinessPolicy::classify (Final => TurnEnded),
variantes ReplyEvent::Heartbeat / ToolActivity.
- application: drain_with_readiness consulte la policy et appelle
mark_idle sur le signal déterministe; branché dans ask_agent.
Corrige la cause racine: une cible qui ne renvoie qu'un Final (sans
idea_reply) débloque désormais sa file Busy.
- infrastructure: adapters de session émettent Heartbeat/ToolActivity.
- tests: drain_with_readiness_lot1 (points QA 5 & 6) verts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1746,6 +1746,36 @@ impl LaunchAgent {
|
||||
},
|
||||
}
|
||||
}
|
||||
domain::profile::McpConfigStrategy::TomlConfigHome { target, home_env } => {
|
||||
// Pendant Codex de `ConfigFile` : on écrit un `config.toml` (table
|
||||
// `[mcp_servers.idea]`, via l'encodeur TOML partagé D2) au chemin
|
||||
// `<run_dir>/<target>`, et on pousse `home_env` (ex. `CODEX_HOME`) vers
|
||||
// le **dossier parent** de ce fichier. Codex lit alors ses serveurs MCP
|
||||
// dans ce `config.toml` ISOLÉ au run dir, jamais le `~/.codex` global.
|
||||
let path = RemotePath::new(join(run_dir, target));
|
||||
let declaration = mcp_server_wiring(mcp.transport, runtime).to_config_toml();
|
||||
// Même régime clobber/non-clobber que `.mcp.json` : avec un runtime réel
|
||||
// (lancement app-tauri) on régénère/clobber à chaque (re)lancement (l'exe
|
||||
// `$APPIMAGE` et l'endpoint dérivent entre runs) ; sans runtime
|
||||
// (orchestrateur / hot-swap / tests) on reste non-clobbering pour ne pas
|
||||
// écraser une déclaration réelle par la minimale.
|
||||
match runtime {
|
||||
Some(_) => {
|
||||
let _ = self.fs.write(&path, declaration.as_bytes()).await;
|
||||
}
|
||||
None => match self.fs.exists(&path).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
let _ = self.fs.write(&path, declaration.as_bytes()).await;
|
||||
}
|
||||
Err(_) => {}
|
||||
},
|
||||
}
|
||||
// `home_env` pointe sur le DOSSIER PARENT de `target` (ex.
|
||||
// `{runDir}/.codex`), pas sur le fichier — Codex y cherche `config.toml`.
|
||||
let home_dir = parent_dir(run_dir, target);
|
||||
spec.env.push((home_env.clone(), home_dir));
|
||||
}
|
||||
domain::profile::McpConfigStrategy::Flag { flag } => {
|
||||
// Pass the server via a launch flag (e.g. `--mcp-config {path}`). The
|
||||
// config path is the run dir itself (the CLI's cwd), where the server
|
||||
@ -1858,58 +1888,40 @@ fn mcp_server_declaration(
|
||||
transport: domain::profile::McpTransport,
|
||||
runtime: Option<&McpRuntime>,
|
||||
) -> String {
|
||||
// Common `.mcp.json`-style shape (Claude Code et CLIs apparentées). The transport
|
||||
// is surfaced so a socket-based CLI can be wired later without changing this seam.
|
||||
let transport_label = match transport {
|
||||
domain::profile::McpTransport::Stdio => "stdio",
|
||||
domain::profile::McpTransport::Socket => "socket",
|
||||
};
|
||||
|
||||
// `command` + extra args depend on whether OS/runtime facts were injected.
|
||||
// Each `args` entry is emitted as an escaped JSON string so an exe path or
|
||||
// endpoint with spaces/backslashes/quotes stays valid JSON.
|
||||
let (command, extra_args) = match runtime {
|
||||
Some(rt) => {
|
||||
let arg = |label: &str, value: &str| {
|
||||
format!(
|
||||
",\n {},\n {}",
|
||||
json_string(label),
|
||||
json_string(value)
|
||||
)
|
||||
};
|
||||
let extra = format!(
|
||||
"{}{}{}",
|
||||
arg("--endpoint", &rt.endpoint),
|
||||
arg("--project", &rt.project_id),
|
||||
arg("--requester", &rt.requester),
|
||||
);
|
||||
(rt.exe.as_str(), extra)
|
||||
}
|
||||
None => ("idea", String::new()),
|
||||
};
|
||||
let command = json_string(command);
|
||||
|
||||
format!(
|
||||
r#"{{
|
||||
"mcpServers": {{
|
||||
"idea": {{
|
||||
"command": {command},
|
||||
"args": [
|
||||
"mcp-server"{extra_args}
|
||||
],
|
||||
"transport": "{transport_label}"
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"#
|
||||
)
|
||||
mcp_server_wiring(transport, runtime).to_mcp_json()
|
||||
}
|
||||
|
||||
/// Wraps a string as a JSON string literal (quotes + escaping), reusing the path
|
||||
/// escaper so an exe path / endpoint with spaces, backslashes or quotes stays valid
|
||||
/// JSON in the generated `.mcp.json`.
|
||||
fn json_string(s: &str) -> String {
|
||||
format!("\"{}\"", json_escape(s))
|
||||
/// Builds the IdeA MCP server **wiring** (`command` + `args` + transport) shared by
|
||||
/// every materialisation format (cadrage Codex D2). It is the **single source of
|
||||
/// truth** for *what* the bridge is launched as; the concrete bytes (`.mcp.json`
|
||||
/// JSON for Claude, `config.toml` TOML for Codex) are produced by the domain
|
||||
/// encoders [`domain::McpServerWiring::to_mcp_json`] /
|
||||
/// [`domain::McpServerWiring::to_config_toml`], so the two sites can never drift.
|
||||
///
|
||||
/// `Some(runtime)` ⇒ the **real** wiring (this IdeA exe + the project's loopback
|
||||
/// endpoint/project/requester); `None` ⇒ the coherent **minimal** `idea mcp-server`
|
||||
/// fallback (orchestrator / hot-swap / tests).
|
||||
#[must_use]
|
||||
fn mcp_server_wiring(
|
||||
transport: domain::profile::McpTransport,
|
||||
runtime: Option<&McpRuntime>,
|
||||
) -> domain::McpServerWiring {
|
||||
let (command, args) = match runtime {
|
||||
Some(rt) => (
|
||||
rt.exe.clone(),
|
||||
vec![
|
||||
"mcp-server".to_owned(),
|
||||
"--endpoint".to_owned(),
|
||||
rt.endpoint.clone(),
|
||||
"--project".to_owned(),
|
||||
rt.project_id.clone(),
|
||||
"--requester".to_owned(),
|
||||
rt.requester.clone(),
|
||||
],
|
||||
),
|
||||
None => ("idea".to_owned(), vec!["mcp-server".to_owned()]),
|
||||
};
|
||||
domain::McpServerWiring::new(command, args, transport)
|
||||
}
|
||||
|
||||
/// Builds an absolute path string by joining a [`ProjectPath`] with a relative
|
||||
@ -1919,6 +1931,18 @@ fn join(base: &ProjectPath, rel: &str) -> String {
|
||||
format!("{b}/{rel}")
|
||||
}
|
||||
|
||||
/// Resolves the **parent directory** (absolute) of `<base>/<rel>` — used to point a
|
||||
/// CLI's `home_env` (e.g. `CODEX_HOME`) at the directory *containing* the materialised
|
||||
/// config file (`config.toml`), not the file itself. When `rel` has no separator
|
||||
/// (file directly in the run dir), the parent is the run dir.
|
||||
fn parent_dir(base: &ProjectPath, rel: &str) -> String {
|
||||
let full = join(base, rel);
|
||||
match full.rsplit_once(['/', '\\']) {
|
||||
Some((parent, _)) if !parent.is_empty() => parent.to_owned(),
|
||||
_ => base.as_str().trim_end_matches(['/', '\\']).to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes an agent's isolated run directory `<root>/.ideai/run/<agent-id>/`
|
||||
/// (ARCHITECTURE §14.1). This is the PTY cwd for the agent — never the project
|
||||
/// root — guaranteeing that two distinct agents on the same project root get two
|
||||
|
||||
Reference in New Issue
Block a user