diff --git a/CLAUDE.md b/CLAUDE.md
index 992a67fd..d5407a8f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,6 +1,6 @@
# Botloft
-Workspace desktop Windows-first, com código público sob a FSL (vira Apache 2.0 após dois anos), para rodar bots de agentes de código sempre ligados que colaboram entre si: Claude Code, Antigravity (experimental) e, em seguida, Codex. A seção 30 da spec e o ADR 0003 dizem como cada agente entra. Daemon Rust (`botloftd`) + app Tauri v2 + React.
+Workspace desktop Windows-first, com código público sob a FSL (vira Apache 2.0 após dois anos), para rodar bots de agentes de código sempre ligados que colaboram entre si: Claude Code, Antigravity e Codex (os dois últimos experimentais). A seção 30 da spec e o ADR 0003 dizem como cada agente entra. Daemon Rust (`botloftd`) + app Tauri v2 + React.
## Fonte de verdade
diff --git a/README.md b/README.md
index f0720801..5a2e1fab 100644
--- a/README.md
+++ b/README.md
@@ -33,8 +33,8 @@
Botloft keeps a crew of bots running on your computer, each on the AI coding agent you choose:
-[Claude Code](https://code.claude.com), or [Antigravity](https://antigravity.google) as an experimental
-option, with Codex next. Each bot has its own folder, memory and conversation. They pass work to each other, keep going after you close the
+[Claude Code](https://code.claude.com), or, as experimental options,
+[Antigravity](https://antigravity.google) and [Codex](https://developers.openai.com/codex). Each bot has its own folder, memory and conversation. They pass work to each other, keep going after you close the
window and pick up where they left off after a restart. You talk to each one like in a chat app, and
you decide what they may do.
@@ -88,15 +88,18 @@ itself.
| Usage meter and "Compact" | yes | an estimate of the conversation size; no compaction |
| Isolation between crews | yes | folders are fenced for its file tools, not for commands you allow |
-Claude Code is the full experience. Antigravity is off until you add `experimental_agents = ["agy"]`
-to Botloft's `config.toml`; then a new bot can be made on it, and Settings has "Agent for new
-bots". Codex is the next agent. See [spec section 30](docs/spec.md) for what was checked.
+Claude Code is the full experience. Antigravity and Codex are off until you list them in Botloft's
+`config.toml` (`experimental_agents = ["agy"]`, `["codex"]` or both); then a new bot can be made on
+them, and Settings has "Agent for new bots". A Codex bot reads files, uses the crew's tools and
+answers, with the exact size of its conversation; it cannot change files or run commands yet, and
+asking you before it acts is the next step. See [spec section 30](docs/spec.md) for what was
+checked.
## Get started
You need at least one agent installed and signed in once, with your own account:
-[Claude Code](https://code.claude.com/docs/en/setup), or Antigravity (experimental, see
-[Agents](#agents)). Bots run on your own plan with that agent.
+[Claude Code](https://code.claude.com/docs/en/setup), Antigravity or Codex
+(experimental, see [Agents](#agents)). Bots run on your own plan with that agent.
1. Download Botloft for your computer from the
[latest release](https://github.com/httpsphl/botloft/releases/latest):
@@ -137,7 +140,7 @@ flowchart LR
scheduled task of your Windows user, a systemd user service on Linux or a launch agent on macOS:
it starts when you sign in and comes back soon if it dies.
- **Your agent is the runtime.** Each bot is a real session of the agent you chose (`claude -p` for
- Claude Code, `agy -p` for Antigravity) with its own workspace, memory and conversation. There is
+ Claude Code, `agy -p` for Antigravity, `codex app-server` for Codex) with its own workspace, memory and conversation. There is
no custom agent SDK and no API key to manage.
- **One conversation per bot.** Your messages, messages from other bots and notices from the daemon
reach the bot in order. What it does comes back as a chat: replies, tool use and requests you
@@ -230,8 +233,8 @@ Windows came first and has had the most use; if something does not work elsewher
Is Botloft made by Anthropic?
-No. Botloft is an independent project. It uses Claude Code and Antigravity as installed on your
-computer and is not affiliated with or endorsed by Anthropic or Google.
+No. Botloft is an independent project. It uses Claude Code, Antigravity and Codex as installed on your
+computer and is not affiliated with or endorsed by Anthropic, Google or OpenAI.
## Status
@@ -247,7 +250,7 @@ and a code-signed installer. `main` can be ahead of the latest release.
Requirements: Rust stable (the MSVC toolchain on Windows), Node.js 24 and pnpm. On Linux, the app
also needs the desktop libraries Tauri uses (on Ubuntu: `libwebkit2gtk-4.1-dev`,
`libayatana-appindicator3-dev`, `librsvg2-dev`, `libxdo-dev`). Running real bots also needs an agent
-installed and signed in: Claude Code (the native `claude` executable) or Antigravity (`agy`).
+installed and signed in: Claude Code (the native `claude` executable), Antigravity (`agy`) or Codex.
```powershell
# Daemon and libraries
diff --git a/app/src/features/bots/BotDialog.tsx b/app/src/features/bots/BotDialog.tsx
index 0bc8646d..6c9f3bdc 100644
--- a/app/src/features/bots/BotDialog.tsx
+++ b/app/src/features/bots/BotDialog.tsx
@@ -148,12 +148,14 @@ export function BotDialog(props: Props) {
/>
{agent !== "claude" && (
- {t.bots.dialog.agent.experimental}
+ {agent === "codex"
+ ? t.bots.dialog.agent.experimentalCodex
+ : t.bots.dialog.agent.experimental}
)}
)}
- {editing && editing.agent !== "claude" && (
+ {editing && editing.agent === "agy" && (
<>
{
expect(dialog.queryByLabelText("Model")).toBeNull();
});
});
+
+describe("Codex in the new-bot dialog", () => {
+ test("says what it cannot do yet", async () => {
+ const fake = new FakeBotloft();
+ fake.system = { ...fake.system, enabledAgents: ["claude", "codex"] };
+ fake.addCrew("Ops");
+ renderApp(fake);
+ await crewOpened("Ops");
+ const [button] = await screen.findAllByRole("button", { name: "New bot" });
+ fireEvent.click(button as HTMLElement);
+ const dialog = within(await screen.findByRole("dialog", { name: "New bot" }));
+ fireEvent.change(dialog.getByLabelText("Agent"), { target: { value: "codex" } });
+ expect(dialog.getByRole("note").textContent).toMatch(/cannot change files or run commands yet/);
+ expect(dialog.queryByLabelText("Model")).toBeNull();
+ });
+});
diff --git a/app/src/features/chat/ChatComposer.tsx b/app/src/features/chat/ChatComposer.tsx
index c0ca0818..d4f1580b 100644
--- a/app/src/features/chat/ChatComposer.tsx
+++ b/app/src/features/chat/ChatComposer.tsx
@@ -254,7 +254,7 @@ export const ChatComposer = memo(function ChatComposer({
>
) : (
-
+ bot.agent === "agy" &&
)}
`Color ${color}`,
diff --git a/app/src/i18n/es/bots.ts b/app/src/i18n/es/bots.ts
index e4fbf604..355e198e 100644
--- a/app/src/i18n/es/bots.ts
+++ b/app/src/i18n/es/bots.ts
@@ -114,6 +114,8 @@ export const bots: Messages["bots"] = {
},
experimental:
"Experimental. Este bot no puede preguntarte antes de actuar. Trabaja con archivos de sus propias carpetas y usa las herramientas del equipo, pero no ejecuta comandos, y no tiene medidor de uso ni elección de modelo.",
+ experimentalCodex:
+ "Experimental. Este bot lee archivos, usa las herramientas del equipo y responde, pero todavía no cambia archivos ni ejecuta comandos. Preguntarte antes de actuar viene a continuación.",
},
color: "Color",
swatch: (color: string) => `Color ${color}`,
diff --git a/app/src/i18n/pt-BR/bots.ts b/app/src/i18n/pt-BR/bots.ts
index 9982d0d4..3f7ec2fa 100644
--- a/app/src/i18n/pt-BR/bots.ts
+++ b/app/src/i18n/pt-BR/bots.ts
@@ -113,6 +113,8 @@ export const bots: Messages["bots"] = {
},
experimental:
"Experimental. Este bot não consegue perguntar a você antes de agir. Ele trabalha com arquivos das próprias pastas e usa as ferramentas da equipe, mas não roda comandos, e não tem medidor de uso nem escolha de modelo.",
+ experimentalCodex:
+ "Experimental. Este bot lê arquivos, usa as ferramentas da equipe e responde, mas ainda não muda arquivos nem roda comandos. Perguntar a você antes de agir vem a seguir.",
},
color: "Cor",
swatch: (color: string) => `Cor ${color}`,
diff --git a/crates/botloftd/src/agent/agy.rs b/crates/botloftd/src/agent/agy.rs
index 0c4b6eaa..876b0cff 100644
--- a/crates/botloftd/src/agent/agy.rs
+++ b/crates/botloftd/src/agent/agy.rs
@@ -115,17 +115,7 @@ impl Agent for AgyAgent {
cli.join("settings.json"),
pretty(&settings(files.fenced, files.allowed_commands)),
)?;
- // The rules `prepare_bot` wrote for Claude Code are the bot's rules;
- // `agy` reads `AGENTS.md` from its folder.
- let rules = files
- .workspace
- .join(".claude")
- .join("rules")
- .join("botloft.md");
- if let Ok(text) = std::fs::read_to_string(rules) {
- std::fs::write(files.workspace.join("AGENTS.md"), text)?;
- }
- Ok(())
+ super::copy_rules_to_agents_md(files.workspace)
}
fn speaks_control(&self) -> bool {
diff --git a/crates/botloftd/src/agent/codex.rs b/crates/botloftd/src/agent/codex.rs
new file mode 100644
index 00000000..34cc13ec
--- /dev/null
+++ b/crates/botloftd/src/agent/codex.rs
@@ -0,0 +1,394 @@
+//! OpenAI's Codex as an agent (spec 30), through `codex app-server`: one
+//! process per bot, JSON-RPC on stdin and stdout. Experimental. The first
+//! slice runs the thread in a read-only sandbox that asks for nothing, so the
+//! bot reads and answers; approvals come next (spec 30.1).
+
+use std::ffi::OsString;
+use std::io;
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+use botloft_core::ids::BotId;
+use botloft_core::protocol::{AgentKind, TokenUsage};
+use bytes::Bytes;
+use serde_json::{Value, json};
+
+use super::codex_wire::{Answer, CodexControl, Shared};
+use super::{Agent, AttachInput, LaunchFiles, LaunchPlan, OutputDecoder, Turn};
+use crate::chat::sink;
+use crate::runtime::ProcessControl;
+use crate::state::Daemon;
+
+pub struct CodexAgent;
+
+impl Agent for CodexAgent {
+ fn kind(&self) -> AgentKind {
+ AgentKind::Codex
+ }
+
+ fn locate(&self, configured: &str, _claude: Option<&Path>) -> io::Result {
+ let configured = configured.trim();
+ if !configured.is_empty() {
+ return Ok(PathBuf::from(configured));
+ }
+ let mut candidates = Vec::new();
+ // The desktop app keeps the executable in a folder named by a hash.
+ if let Some(local) = std::env::var_os("LOCALAPPDATA") {
+ let bin = PathBuf::from(local)
+ .join("OpenAI")
+ .join("Codex")
+ .join("bin");
+ let mut found: Vec<_> = std::fs::read_dir(bin)
+ .into_iter()
+ .flatten()
+ .flatten()
+ .map(|entry| entry.path().join("codex.exe"))
+ .filter(|path| path.is_file())
+ .collect();
+ found.sort_by_key(|path| std::fs::metadata(path).and_then(|m| m.modified()).ok());
+ candidates.extend(found.into_iter().rev());
+ }
+ if let Some(path) = std::env::var_os("PATH") {
+ for dir in std::env::split_paths(&path) {
+ candidates.push(dir.join("codex.exe"));
+ candidates.push(dir.join("codex"));
+ }
+ }
+ candidates
+ .into_iter()
+ .find(|candidate| candidate.is_file())
+ .ok_or_else(|| {
+ io::Error::new(
+ io::ErrorKind::NotFound,
+ "codex was not found; install Codex or set codex_path in config.toml",
+ )
+ })
+ }
+
+ fn args(&self, _plan: &LaunchPlan<'_>) -> Vec {
+ let mut overrides = vec![
+ // The owner's own apps, hooks, plugins and notifier are theirs
+ // (spec 30.3); their MCP servers go by name below.
+ "features.apps=false".to_owned(),
+ "features.hooks=false".to_owned(),
+ "features.plugins=false".to_owned(),
+ "notify=[]".to_owned(),
+ ];
+ overrides.extend(
+ owner_mcp_servers()
+ .into_iter()
+ .map(|name| format!("mcp_servers.{name}.enabled=false")),
+ );
+ let mut args = vec![OsString::from("app-server")];
+ for value in overrides {
+ args.push("-c".into());
+ args.push(value.into());
+ }
+ args
+ }
+
+ fn extra_env(&self, _workspace: &Path) -> Vec<(OsString, OsString)> {
+ Vec::new()
+ }
+
+ fn write_launch_files(&self, files: &LaunchFiles<'_>) -> io::Result<()> {
+ super::copy_rules_to_agents_md(files.workspace)
+ }
+
+ fn speaks_control(&self) -> bool {
+ false
+ }
+
+ fn encode_turn(&self, _uuid: &str, turn: &Turn) -> Bytes {
+ // The wrapper turns this neutral line into a `turn/start`.
+ super::neutral_turn(turn)
+ }
+
+ fn decoder(&self) -> Box {
+ // Not used: `attach` makes the decoder, which needs the process.
+ Box::new(Silent)
+ }
+
+ fn attach(
+ &self,
+ control: Box,
+ input: AttachInput,
+ ) -> (Box, Box) {
+ let shared = Shared::begin(control, input);
+ let decoder = CodexDecoder {
+ shared: Arc::clone(&shared),
+ thread: None,
+ window: 0,
+ base: (0, 0, 0, 0),
+ usage: (0, 0, 0, 0),
+ };
+ (Box::new(CodexControl { shared }), Box::new(decoder))
+ }
+}
+
+struct Silent;
+
+impl OutputDecoder for Silent {
+ fn live_text<'a>(&mut self, _event: &'a Value) -> Option<&'a str> {
+ None
+ }
+
+ fn handle(&mut self, _daemon: &Daemon, _bot: &BotId, _generation: u64, _event: &Value) {}
+}
+
+/// The names of the MCP servers in the owner's `config.toml`, to switch off.
+fn owner_mcp_servers() -> Vec {
+ let home = std::env::var_os("CODEX_HOME")
+ .map(PathBuf::from)
+ .or_else(|| std::env::var_os("USERPROFILE").map(|p| PathBuf::from(p).join(".codex")));
+ let Some(text) = home.and_then(|home| std::fs::read_to_string(home.join("config.toml")).ok())
+ else {
+ return Vec::new();
+ };
+ let Ok(document) = text.parse::() else {
+ return Vec::new();
+ };
+ document
+ .get("mcp_servers")
+ .and_then(toml_edit::Item::as_table_like)
+ .map(|servers| {
+ servers
+ .iter()
+ .map(|(name, _)| name.to_owned())
+ // A name that is not plain would need quoting in the flag.
+ .filter(|name| {
+ name.chars()
+ .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
+ })
+ .collect()
+ })
+ .unwrap_or_default()
+}
+
+/// Reads what the server prints and tells the daemon.
+struct CodexDecoder {
+ shared: Arc,
+ thread: Option,
+ /// The model's context window, from `thread/tokenUsage/updated`.
+ window: u64,
+ /// What the thread has used (input, cached, output, reasoning), and what
+ /// it had used when this turn began.
+ usage: (u64, u64, u64, u64),
+ base: (u64, u64, u64, u64),
+}
+
+impl OutputDecoder for CodexDecoder {
+ fn live_text<'a>(&mut self, event: &'a Value) -> Option<&'a str> {
+ if event["method"] != "item/agentMessage/delta" {
+ return None;
+ }
+ event["params"]["delta"]
+ .as_str()
+ .filter(|text| !text.is_empty())
+ }
+
+ fn handle(&mut self, daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) {
+ if !daemon.supervisor.is_current(bot, generation) {
+ return;
+ }
+ let method = event["method"].as_str();
+ match (method, event.get("id")) {
+ (None, Some(_)) => self.response(daemon, bot, generation, event),
+ (Some(method), Some(id)) => self.server_request(method, id, &event["params"]),
+ (Some(method), None) => {
+ self.notification(daemon, bot, generation, method, &event["params"])
+ }
+ (None, None) => {}
+ }
+ }
+}
+
+impl CodexDecoder {
+ fn response(&mut self, daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) {
+ match self.shared.answered(event) {
+ Answer::Thread { thread, model } => {
+ self.thread = Some(thread);
+ if let Some(model) = model {
+ crate::service::models::reported(daemon, bot, &model);
+ }
+ }
+ Answer::TurnRefused(message) => {
+ // No turn began, so the message is read and the turn failed.
+ if let Some(uuid) = daemon.supervisor.oldest_message_began(bot, generation) {
+ sink::message_read(daemon, bot, &uuid);
+ }
+ let error = message.unwrap_or_else(|| "the turn was refused".to_owned());
+ sink::turn_finished(daemon, bot, 0, None, Some(error));
+ sink::finish_turn(daemon, bot, generation, true);
+ }
+ Answer::Failed(message) => {
+ tracing::warn!(bot = %bot, "codex could not start a thread: {message:?}");
+ }
+ Answer::Lost | Answer::None => {}
+ }
+ }
+
+ /// Requests the daemon does not take yet (A3a) are declined, except the
+ /// call of Botloft's own tools, which the bot needs to work in the crew.
+ fn server_request(&self, method: &str, id: &Value, params: &Value) {
+ let result = match method {
+ "mcpServer/elicitation/request" if params["serverName"] == "botloft" => {
+ json!({ "action": "accept", "content": null })
+ }
+ "mcpServer/elicitation/request" => json!({ "action": "decline", "content": null }),
+ "item/commandExecution/requestApproval" | "item/fileChange/requestApproval" => {
+ json!({ "decision": "decline" })
+ }
+ "applyPatchApproval" | "execCommandApproval" => json!({ "decision": "denied" }),
+ _ => {
+ self.shared.refuse(id, "not supported by Botloft yet");
+ return;
+ }
+ };
+ self.shared.respond(id, result);
+ }
+
+ fn notification(
+ &mut self,
+ daemon: &Daemon,
+ bot: &BotId,
+ generation: u64,
+ method: &str,
+ params: &Value,
+ ) {
+ match method {
+ "turn/started" => {
+ self.base = self.usage;
+ daemon.supervisor.turn_began(bot, generation);
+ }
+ "item/started" => self.item_started(daemon, bot, generation, params),
+ "item/completed" => self.item_completed(daemon, bot, ¶ms["item"]),
+ "thread/tokenUsage/updated" => self.token_usage(daemon, bot, ¶ms["tokenUsage"]),
+ "turn/completed" => self.turn_completed(daemon, bot, generation, ¶ms["turn"]),
+ _ => {}
+ }
+ }
+
+ fn item_started(&mut self, daemon: &Daemon, bot: &BotId, generation: u64, params: &Value) {
+ let item = ¶ms["item"];
+ let id = item["id"].as_str().unwrap_or_default();
+ match item["type"].as_str() {
+ // The message was taken up: the oldest one written.
+ Some("userMessage") => {
+ if let Some(thread) = params["threadId"].as_str().or(self.thread.as_deref()) {
+ sink::session_started(daemon, bot, thread);
+ }
+ if let Some(uuid) = daemon.supervisor.oldest_message_began(bot, generation) {
+ sink::message_read(daemon, bot, &uuid);
+ }
+ }
+ Some("commandExecution") => {
+ let command = item["commandActions"][0]["command"]
+ .as_str()
+ .or_else(|| item["command"].as_str())
+ .unwrap_or_default();
+ sink::tool_started(daemon, bot, id, "Bash", &json!({ "command": command }));
+ }
+ Some("fileChange") => {
+ let change = &item["changes"][0];
+ let name = if change["kind"]["type"] == "add" {
+ "Write"
+ } else {
+ "Edit"
+ };
+ sink::tool_started(
+ daemon,
+ bot,
+ id,
+ name,
+ &json!({ "file_path": change["path"] }),
+ );
+ }
+ Some("mcpToolCall") => {
+ let name = format!(
+ "mcp__{}__{}",
+ item["server"].as_str().unwrap_or("mcp"),
+ item["tool"].as_str().unwrap_or("tool")
+ );
+ sink::tool_started(daemon, bot, id, &name, &item["arguments"]);
+ }
+ _ => {}
+ }
+ }
+
+ fn item_completed(&mut self, daemon: &Daemon, bot: &BotId, item: &Value) {
+ let id = item["id"].as_str().unwrap_or_default();
+ let ok = item["status"].as_str() == Some("completed");
+ match item["type"].as_str() {
+ Some("agentMessage") => {
+ sink::reply(daemon, bot, item["text"].as_str().unwrap_or_default());
+ }
+ Some("commandExecution") => {
+ let failed = !ok || item["exitCode"].as_i64().is_some_and(|code| code != 0);
+ let output = item["aggregatedOutput"].as_str().unwrap_or_default();
+ sink::tool_finished(daemon, bot, id, failed, output);
+ }
+ Some("fileChange") => sink::tool_finished(daemon, bot, id, !ok, ""),
+ Some("mcpToolCall") => {
+ let output = item["result"]["content"]
+ .as_array()
+ .map(|parts| {
+ parts
+ .iter()
+ .filter_map(|part| part["text"].as_str())
+ .collect::>()
+ .join("\n")
+ })
+ .or_else(|| item["error"]["message"].as_str().map(str::to_owned))
+ .unwrap_or_default();
+ sink::tool_finished(daemon, bot, id, !ok, &output);
+ }
+ _ => {}
+ }
+ }
+
+ fn token_usage(&mut self, daemon: &Daemon, bot: &BotId, usage: &Value) {
+ let count = |value: &Value, key: &str| value[key].as_u64().unwrap_or_default();
+ self.window = count(usage, "modelContextWindow");
+ let last = &usage["last"];
+ self.usage = (
+ count(&usage["total"], "inputTokens"),
+ count(&usage["total"], "cachedInputTokens"),
+ count(&usage["total"], "outputTokens"),
+ count(&usage["total"], "reasoningOutputTokens"),
+ );
+ // The last request held the conversation up to that point.
+ let held = count(last, "inputTokens") + count(last, "outputTokens");
+ if held > 0 && self.window > 0 {
+ crate::context::report(daemon, bot, held, self.window);
+ }
+ }
+
+ fn turn_completed(&mut self, daemon: &Daemon, bot: &BotId, generation: u64, turn: &Value) {
+ let failed = turn["status"].as_str() != Some("completed");
+ let error = failed.then(|| {
+ turn["error"]["message"]
+ .as_str()
+ .or_else(|| turn["status"].as_str())
+ .unwrap_or("error")
+ .to_owned()
+ });
+ let (input, cached, output, reasoning) = (
+ self.usage.0.saturating_sub(self.base.0),
+ self.usage.1.saturating_sub(self.base.1),
+ self.usage.2.saturating_sub(self.base.2),
+ self.usage.3.saturating_sub(self.base.3),
+ );
+ let tokens = (self.usage != self.base).then(|| TokenUsage {
+ input: input.saturating_sub(cached),
+ cache_write: 0,
+ reloaded: 0,
+ cache_read: cached,
+ output: output + reasoning,
+ });
+ let duration = turn["durationMs"].as_u64().unwrap_or_default();
+ sink::turn_finished(daemon, bot, duration, tokens, error);
+ self.shared.turn_done();
+ sink::finish_turn(daemon, bot, generation, failed);
+ }
+}
diff --git a/crates/botloftd/src/agent/codex_wire.rs b/crates/botloftd/src/agent/codex_wire.rs
new file mode 100644
index 00000000..e8f357c2
--- /dev/null
+++ b/crates/botloftd/src/agent/codex_wire.rs
@@ -0,0 +1,382 @@
+//! The conversation with `codex app-server` (spec 30): JSON-RPC lines on
+//! stdin and stdout. The daemon writes Botloft's own neutral turn line to the
+//! process (one JSON object with `"event": "user"`); [`CodexControl`] turns it
+//! into a `turn/start` once the thread exists and no turn runs, and the
+//! [`super::codex::CodexDecoder`] reads the answers through the same
+//! [`Shared`] state.
+
+use std::collections::{HashMap, VecDeque};
+use std::io;
+use std::sync::{Arc, Mutex, MutexGuard};
+
+use bytes::Bytes;
+use serde_json::{Value, json};
+
+use super::AttachInput;
+use crate::runtime::ProcessControl;
+
+/// What a request that was sent is waiting for.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Pending {
+ Initialize,
+ ThreadStart,
+ ThreadResume,
+ Turn,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(super) enum Phase {
+ /// `initialize` or the thread request is still on its way.
+ Starting,
+ Ready {
+ thread: String,
+ },
+ /// The thread could not be made: nothing will run.
+ Failed,
+}
+
+pub(super) struct State {
+ next_id: u64,
+ phase: Phase,
+ pending: HashMap,
+ /// Owner messages (text) waiting for the thread or for the running turn.
+ queue: VecDeque,
+ turn_active: bool,
+}
+
+pub(super) struct Shared {
+ real: Box,
+ input: AttachInput,
+ state: Mutex,
+}
+
+impl Shared {
+ pub(super) fn begin(real: Box, input: AttachInput) -> Arc {
+ let shared = Arc::new(Self {
+ real,
+ input,
+ state: Mutex::new(State {
+ next_id: 1,
+ phase: Phase::Starting,
+ pending: HashMap::new(),
+ queue: VecDeque::new(),
+ turn_active: false,
+ }),
+ });
+ {
+ let mut state = shared.lock();
+ shared.request(
+ &mut state,
+ "initialize",
+ json!({ "clientInfo": { "name": "botloft", "version": env!("CARGO_PKG_VERSION") } }),
+ Pending::Initialize,
+ );
+ }
+ shared
+ }
+
+ fn lock(&self) -> MutexGuard<'_, State> {
+ self.state
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ }
+
+ fn send(&self, message: &Value) {
+ let mut line = message.to_string().into_bytes();
+ line.push(b'\n');
+ // A process that is gone has its exit event on the way.
+ let _ = self.real.write(Bytes::from(line));
+ }
+
+ fn request(&self, state: &mut State, method: &str, params: Value, kind: Pending) {
+ let id = state.next_id;
+ state.next_id += 1;
+ state.pending.insert(id, kind);
+ self.send(&json!({ "id": id, "method": method, "params": params }));
+ }
+
+ /// What the thread is made with: where it works, that it asks for
+ /// nothing yet (A3a), the model, and Botloft's MCP server with the
+ /// token of this process.
+ fn thread_params(&self) -> Value {
+ let mut params = json!({
+ "cwd": self.input.workspace,
+ "approvalPolicy": "never",
+ "sandbox": "read-only",
+ "config": {
+ "mcp_servers": {
+ "botloft": {
+ "url": format!("http://127.0.0.1:{}/mcp", self.input.port),
+ "http_headers": { "Authorization": format!("Bearer {}", self.input.token) },
+ },
+ },
+ },
+ });
+ if let Some(model) = &self.input.model {
+ params["model"] = json!(model);
+ }
+ params
+ }
+
+ /// A message from the daemon (the neutral line): queued, and sent when
+ /// the thread is ready and nothing runs.
+ pub(super) fn enqueue(&self, line: &[u8]) -> io::Result<()> {
+ let event: Value = serde_json::from_slice(line)
+ .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
+ let text = event["message"]["content"][0]["text"]
+ .as_str()
+ .unwrap_or_default()
+ .to_owned();
+ let mut state = self.lock();
+ state.queue.push_back(text);
+ self.pump(&mut state);
+ Ok(())
+ }
+
+ fn pump(&self, state: &mut State) {
+ let Phase::Ready { thread } = &state.phase else {
+ return;
+ };
+ if state.turn_active {
+ return;
+ }
+ let Some(text) = state.queue.pop_front() else {
+ return;
+ };
+ let mut params = json!({
+ "threadId": thread,
+ "input": [{ "type": "text", "text": text }],
+ });
+ if let Some(effort) = &self.input.effort {
+ params["effort"] = json!(effort);
+ }
+ state.turn_active = true;
+ self.request(state, "turn/start", params, Pending::Turn);
+ }
+
+ /// A response line (`id` and `result` or `error`). Returns the id of a
+ /// thread that became ready, for the caller to remember.
+ pub(super) fn answered(&self, event: &Value) -> Answer {
+ let Some(id) = event["id"].as_u64() else {
+ return Answer::None;
+ };
+ let mut state = self.lock();
+ let Some(kind) = state.pending.remove(&id) else {
+ return Answer::None;
+ };
+ let failed = !event["error"].is_null();
+ match kind {
+ Pending::Initialize if !failed => {
+ self.send(&json!({ "method": "initialized", "params": {} }));
+ let params = self.thread_params();
+ match &self.input.resume {
+ Some(thread) => {
+ let mut params = params;
+ params["threadId"] = json!(thread);
+ self.request(&mut state, "thread/resume", params, Pending::ThreadResume);
+ }
+ None => self.request(&mut state, "thread/start", params, Pending::ThreadStart),
+ }
+ Answer::None
+ }
+ Pending::ThreadResume if failed => {
+ // The conversation is gone: a new one takes its place.
+ let params = self.thread_params();
+ self.request(&mut state, "thread/start", params, Pending::ThreadStart);
+ Answer::Lost
+ }
+ Pending::ThreadStart | Pending::ThreadResume if !failed => {
+ let thread = event["result"]["thread"]["id"]
+ .as_str()
+ .unwrap_or_default()
+ .to_owned();
+ state.phase = Phase::Ready {
+ thread: thread.clone(),
+ };
+ self.pump(&mut state);
+ Answer::Thread {
+ thread,
+ model: event["result"]["model"].as_str().map(str::to_owned),
+ }
+ }
+ Pending::Turn if failed => {
+ state.turn_active = false;
+ self.pump(&mut state);
+ Answer::TurnRefused(event["error"]["message"].as_str().map(str::to_owned))
+ }
+ Pending::Initialize | Pending::ThreadStart | Pending::ThreadResume => {
+ state.phase = Phase::Failed;
+ Answer::Failed(event["error"]["message"].as_str().map(str::to_owned))
+ }
+ Pending::Turn => Answer::None,
+ }
+ }
+
+ /// The turn ended: the next queued message may go.
+ pub(super) fn turn_done(&self) {
+ let mut state = self.lock();
+ state.turn_active = false;
+ self.pump(&mut state);
+ }
+
+ /// Refuses a request of the server that Botloft does not take.
+ pub(super) fn refuse(&self, id: &Value, message: &str) {
+ self.send(&json!({ "id": id, "error": { "code": -32601, "message": message } }));
+ }
+
+ /// Answers a request of the server.
+ pub(super) fn respond(&self, id: &Value, result: Value) {
+ self.send(&json!({ "id": id, "result": result }));
+ }
+}
+
+/// What an answer of the server changed for the daemon.
+pub(super) enum Answer {
+ None,
+ /// The thread is ready; this is its id, and the model it runs on.
+ Thread {
+ thread: String,
+ model: Option,
+ },
+ /// The conversation to resume was not there.
+ Lost,
+ /// `turn/start` was refused.
+ TurnRefused(Option),
+ /// The process cannot start a thread.
+ Failed(Option),
+}
+
+/// What the supervisor writes to: Botloft's turn lines in, JSON-RPC out.
+pub(super) struct CodexControl {
+ pub(super) shared: Arc,
+}
+
+impl ProcessControl for CodexControl {
+ fn write(&self, data: Bytes) -> io::Result<()> {
+ self.shared.enqueue(&data)
+ }
+
+ fn kill(&self) -> io::Result<()> {
+ self.shared.real.kill()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::sync::Mutex as StdMutex;
+
+ use super::*;
+
+ /// Collects what is written, as lines.
+ #[derive(Default)]
+ struct Sink(Arc>>);
+
+ impl ProcessControl for Sink {
+ fn write(&self, data: Bytes) -> io::Result<()> {
+ let line = String::from_utf8_lossy(&data).into_owned();
+ self.0
+ .lock()
+ .expect("lock")
+ .push(serde_json::from_str(line.trim()).expect("json"));
+ Ok(())
+ }
+
+ fn kill(&self) -> io::Result<()> {
+ Ok(())
+ }
+ }
+
+ fn input(resume: Option<&str>) -> AttachInput {
+ AttachInput {
+ workspace: "w".into(),
+ port: 45710,
+ token: "tok".into(),
+ resume: resume.map(str::to_owned),
+ model: Some("gpt-5.5".into()),
+ effort: None,
+ }
+ }
+
+ fn user(text: &str) -> Vec {
+ json!({ "event": "user", "message": { "role": "user", "content": [{ "type": "text", "text": text }] } })
+ .to_string()
+ .into_bytes()
+ }
+
+ #[test]
+ fn a_message_waits_for_the_thread_and_then_for_the_turn_before_it() {
+ let sink = Sink::default();
+ let written = Arc::clone(&sink.0);
+ let shared = Shared::begin(Box::new(sink), input(None));
+ let methods = || -> Vec {
+ written
+ .lock()
+ .expect("lock")
+ .iter()
+ .map(|line| line["method"].as_str().unwrap_or("response").to_owned())
+ .collect()
+ };
+ assert_eq!(methods(), ["initialize"]);
+
+ // Messages before the thread are held.
+ shared.enqueue(&user("one")).expect("queued");
+ shared.enqueue(&user("two")).expect("queued");
+ assert_eq!(methods(), ["initialize"]);
+
+ shared.answered(&json!({ "id": 1, "result": {} }));
+ assert_eq!(methods(), ["initialize", "initialized", "thread/start"]);
+ let start = written.lock().expect("lock")[2].clone();
+ assert_eq!(start["params"]["approvalPolicy"], "never");
+ assert_eq!(start["params"]["sandbox"], "read-only");
+ assert_eq!(start["params"]["model"], "gpt-5.5");
+ assert_eq!(
+ start["params"]["config"]["mcp_servers"]["botloft"]["http_headers"]["Authorization"],
+ "Bearer tok"
+ );
+
+ let answer = shared.answered(&json!({ "id": 2, "result": { "thread": { "id": "th-1" } } }));
+ assert!(matches!(answer, Answer::Thread { thread, .. } if thread == "th-1"));
+ // Only the first message goes; the second waits for its turn.
+ let turns: Vec = written
+ .lock()
+ .expect("lock")
+ .iter()
+ .filter(|line| line["method"] == "turn/start")
+ .cloned()
+ .collect();
+ assert_eq!(turns.len(), 1);
+ assert_eq!(turns[0]["params"]["threadId"], "th-1");
+ assert_eq!(turns[0]["params"]["input"][0]["text"], "one");
+
+ shared.turn_done();
+ let texts: Vec = written
+ .lock()
+ .expect("lock")
+ .iter()
+ .filter(|line| line["method"] == "turn/start")
+ .map(|line| {
+ line["params"]["input"][0]["text"]
+ .as_str()
+ .unwrap_or_default()
+ .to_owned()
+ })
+ .collect();
+ assert_eq!(texts, ["one", "two"]);
+ }
+
+ #[test]
+ fn a_conversation_that_is_gone_starts_a_new_one() {
+ let sink = Sink::default();
+ let written = Arc::clone(&sink.0);
+ let shared = Shared::begin(Box::new(sink), input(Some("old")));
+ shared.answered(&json!({ "id": 1, "result": {} }));
+ let resume = written.lock().expect("lock")[2].clone();
+ assert_eq!(resume["method"], "thread/resume");
+ assert_eq!(resume["params"]["threadId"], "old");
+
+ let answer = shared.answered(&json!({ "id": 2, "error": { "message": "no rollout" } }));
+ assert!(matches!(answer, Answer::Lost));
+ let last = written.lock().expect("lock").last().cloned().expect("line");
+ assert_eq!(last["method"], "thread/start");
+ }
+}
diff --git a/crates/botloftd/src/agent/mod.rs b/crates/botloftd/src/agent/mod.rs
index f1b68698..3c300296 100644
--- a/crates/botloftd/src/agent/mod.rs
+++ b/crates/botloftd/src/agent/mod.rs
@@ -5,6 +5,8 @@
mod agy;
mod claude;
+mod codex;
+mod codex_wire;
use std::ffi::OsString;
use std::io;
@@ -17,7 +19,9 @@ use serde_json::Value;
pub use agy::{AgyAgent, models as agy_models};
pub use claude::ClaudeAgent;
+pub use codex::CodexAgent;
+use crate::runtime::ProcessControl;
use crate::state::Daemon;
/// One image that goes in the turn, already read and encoded.
@@ -64,6 +68,21 @@ pub struct LaunchFiles<'a> {
pub allowed_commands: &'a [String],
}
+/// What an agent that keeps a conversation with its process needs once the
+/// process exists (spec 30): where the bot works, its token, what to resume.
+#[derive(Debug, Clone)]
+pub struct AttachInput {
+ pub workspace: PathBuf,
+ pub port: u16,
+ /// The token of this process, in the clear: it goes to the agent's MCP
+ /// configuration.
+ pub token: String,
+ /// The conversation to resume; `None` starts a new one.
+ pub resume: Option,
+ pub model: Option,
+ pub effort: Option,
+}
+
/// Reads one process's output, line by line (already JSON). One per process,
/// so it may keep what it needs between lines.
pub trait OutputDecoder: Send {
@@ -106,6 +125,39 @@ pub trait Agent: Send + Sync + 'static {
/// A reader for the output of one process.
fn decoder(&self) -> Box;
+
+ /// What the supervisor writes to and what reads the output, for a
+ /// process that was just created. An agent that has a conversation with
+ /// its process (Codex) wraps `control`; the others leave it as it is.
+ fn attach(
+ &self,
+ control: Box,
+ _input: AttachInput,
+ ) -> (Box, Box) {
+ (control, self.decoder())
+ }
+}
+
+/// Botloft's own line for a turn, for agents whose stdin is a protocol of
+/// their own that a wrapper speaks: text only, as `agy` takes it.
+pub(crate) fn neutral_turn(turn: &Turn) -> Bytes {
+ let event = serde_json::json!({
+ "event": "user",
+ "message": { "role": "user", "content": [{ "type": "text", "text": turn.text }] },
+ });
+ let mut line = event.to_string().into_bytes();
+ line.push(b'\n');
+ Bytes::from(line)
+}
+
+/// The rules `prepare_bot` wrote for Claude Code are the bot's rules; the
+/// other agents read `AGENTS.md` from the bot's folder.
+pub(crate) fn copy_rules_to_agents_md(workspace: &Path) -> io::Result<()> {
+ let rules = workspace.join(".claude").join("rules").join("botloft.md");
+ if let Ok(text) = std::fs::read_to_string(rules) {
+ std::fs::write(workspace.join("AGENTS.md"), text)?;
+ }
+ Ok(())
}
/// The first line `program --version` prints; `None` if it cannot run.
@@ -124,13 +176,15 @@ pub fn program_version(program: &Path) -> Option {
static CLAUDE: ClaudeAgent = ClaudeAgent;
static AGY: AgyAgent = AgyAgent;
+static CODEX: CodexAgent = CodexAgent;
-/// The agent that runs bots of `kind`; `None` for one the daemon does not
-/// have yet (spec 30.1).
+/// The agent that runs bots of `kind`.
pub fn of(kind: AgentKind) -> Option<&'static dyn Agent> {
+ // Every agent has an implementation; whether it may run is the daemon's
+ // setting (`Supervisor::agent_enabled`).
match kind {
AgentKind::Claude => Some(&CLAUDE),
AgentKind::Agy => Some(&AGY),
- AgentKind::Codex => None,
+ AgentKind::Codex => Some(&CODEX),
}
}
diff --git a/crates/botloftd/src/chat/mod.rs b/crates/botloftd/src/chat/mod.rs
index eb2b713e..2389993d 100644
--- a/crates/botloftd/src/chat/mod.rs
+++ b/crates/botloftd/src/chat/mod.rs
@@ -13,7 +13,7 @@ use botloft_core::ids::BotId;
use botloft_core::protocol::ChatDelta;
use tracing::debug;
-use crate::agent::{Agent, OutputDecoder};
+use crate::agent::OutputDecoder;
use crate::state::{Daemon, Event};
/// Longest line kept; anything longer is dropped whole (spec 8.1).
@@ -41,11 +41,11 @@ pub struct StreamReader {
}
impl StreamReader {
- pub fn new(bot: BotId, generation: u64, agent: &'static dyn Agent) -> Self {
+ pub fn new(bot: BotId, generation: u64, decoder: Box) -> Self {
Self {
bot,
generation,
- decoder: agent.decoder(),
+ decoder,
buffer: Vec::new(),
skipping: false,
live: String::new(),
diff --git a/crates/botloftd/src/config.rs b/crates/botloftd/src/config.rs
index 3a4e605a..b3304234 100644
--- a/crates/botloftd/src/config.rs
+++ b/crates/botloftd/src/config.rs
@@ -16,6 +16,8 @@ pub struct Config {
pub claude_path: String,
/// Empty means look for `agy` in its installer's folder and on PATH.
pub agy_path: String,
+ /// Empty means look for `codex` in the desktop app's folder and on PATH.
+ pub codex_path: String,
/// The agent for new bots: `"claude"`, or an enabled experimental one
/// (spec 30). Changed in the app's Settings.
pub default_agent: String,
@@ -42,6 +44,7 @@ impl Default for Config {
workspaces_root: String::new(),
claude_path: String::new(),
agy_path: String::new(),
+ codex_path: String::new(),
default_agent: "claude".to_owned(),
experimental_agents: Vec::new(),
start_with_windows: true,
diff --git a/crates/botloftd/src/service/mod.rs b/crates/botloftd/src/service/mod.rs
index 5b30b131..e14f7125 100644
--- a/crates/botloftd/src/service/mod.rs
+++ b/crates/botloftd/src/service/mod.rs
@@ -142,15 +142,19 @@ fn agent_checks(daemon: &Daemon) -> Vec {
return checks.clone();
}
let mut checks = Vec::new();
- if daemon.supervisor.agent_enabled(AgentKind::Agy)
- && let Some(agent) = crate::agent::of(AgentKind::Agy)
- {
+ for kind in [AgentKind::Agy, AgentKind::Codex] {
+ if !daemon.supervisor.agent_enabled(kind) {
+ continue;
+ }
+ let Some(agent) = crate::agent::of(kind) else {
+ continue;
+ };
let version = agent
- .locate(daemon.supervisor.agy_path(), None)
+ .locate(daemon.supervisor.program_setting(kind), None)
.ok()
.and_then(|program| crate::agent::program_version(&program));
checks.push(AgentCheck {
- agent: AgentKind::Agy,
+ agent: kind,
version,
});
}
diff --git a/crates/botloftd/src/supervisor/mod.rs b/crates/botloftd/src/supervisor/mod.rs
index 6505bb09..5fcca153 100644
--- a/crates/botloftd/src/supervisor/mod.rs
+++ b/crates/botloftd/src/supervisor/mod.rs
@@ -133,7 +133,7 @@ impl Supervisor {
/// Whether bots of `kind` may be made: Claude Code always, an
/// experimental agent only when the owner listed it in `config.toml`
- /// (spec 30); Codex does not run yet.
+ /// (spec 30).
pub fn agent_enabled(&self, kind: botloft_core::protocol::AgentKind) -> bool {
use botloft_core::protocol::AgentKind;
match kind {
@@ -143,7 +143,11 @@ impl Supervisor {
.experimental_agents
.iter()
.any(|name| name == "agy"),
- AgentKind::Codex => false,
+ AgentKind::Codex => self
+ .settings
+ .experimental_agents
+ .iter()
+ .any(|name| name == "codex"),
}
}
@@ -152,6 +156,21 @@ impl Supervisor {
&self.settings.agy_path
}
+ /// The `codex_path` of the config; empty looks in the usual places.
+ pub fn codex_path(&self) -> &str {
+ &self.settings.codex_path
+ }
+
+ /// The configured program of an agent, to look for it.
+ pub fn program_setting(&self, kind: botloft_core::protocol::AgentKind) -> &str {
+ use botloft_core::protocol::AgentKind;
+ match kind {
+ AgentKind::Claude => "",
+ AgentKind::Agy => self.agy_path(),
+ AgentKind::Codex => self.codex_path(),
+ }
+ }
+
/// The Claude Code executable the bots run, once found.
pub fn claude_path(&self) -> Option {
match &self.lock().claude {
diff --git a/crates/botloftd/src/supervisor/settings.rs b/crates/botloftd/src/supervisor/settings.rs
index c78d6077..b41e3a9c 100644
--- a/crates/botloftd/src/supervisor/settings.rs
+++ b/crates/botloftd/src/supervisor/settings.rs
@@ -18,6 +18,8 @@ pub struct SupervisorSettings {
pub claude: ClaudeSource,
/// `agy_path` from the config; empty looks in the usual places (spec 30).
pub agy_path: String,
+ /// `codex_path` from the config; empty looks in the usual places.
+ pub codex_path: String,
/// Agents the owner switched on although they are experimental.
pub experimental_agents: Vec,
pub backoff_initial: Duration,
@@ -35,6 +37,7 @@ impl SupervisorSettings {
configured: config.claude_path.clone(),
},
agy_path: config.agy_path.clone(),
+ codex_path: config.codex_path.clone(),
experimental_agents: config.experimental_agents.clone(),
backoff_initial: Duration::from_millis(config.supervisor.restart_backoff_initial_ms),
backoff_max: Duration::from_millis(config.supervisor.restart_backoff_max_ms),
diff --git a/crates/botloftd/src/supervisor/spawn.rs b/crates/botloftd/src/supervisor/spawn.rs
index f70a498e..e7770b12 100644
--- a/crates/botloftd/src/supervisor/spawn.rs
+++ b/crates/botloftd/src/supervisor/spawn.rs
@@ -13,7 +13,7 @@ use tracing::debug;
use super::slot::StopIntent;
use super::{Inner, STABLE_AFTER, Supervisor};
-use crate::agent::{self, LaunchFiles, LaunchPlan};
+use crate::agent::{self, AttachInput, LaunchFiles, LaunchPlan};
use crate::chat::{LIVE_TEXT_EVERY, StreamReader};
use crate::platform;
use crate::runtime::claude::Claude;
@@ -85,6 +85,8 @@ impl Supervisor {
}
pub(super) struct Launch {
+ /// What the agent needs once the process exists.
+ pub attach: AttachInput,
pub token_hash: String,
pub resumed: bool,
/// Whether the bot starts with connected tools (spec 25).
@@ -117,7 +119,7 @@ pub(super) fn launch_spec(
io::Error::other(format!("bots on {} cannot start yet", bot.agent.as_str()))
})?;
let program = agent.locate(
- daemon.supervisor.agy_path(),
+ daemon.supervisor.program_setting(bot.agent),
claude.map(|claude| claude.path.as_path()),
)?;
let mcp = workspace.join(".botloft").join("mcp.json");
@@ -175,6 +177,14 @@ pub(super) fn launch_spec(
Ok((
spec,
Launch {
+ attach: AttachInput {
+ workspace: workspace.clone(),
+ port: daemon.port,
+ token: token.clone(),
+ resume: resumed.then(|| session.clone()),
+ model: bot.agent_model.clone(),
+ effort: bot.effort.cli_value().map(str::to_owned),
+ },
token_hash: TokenHash::of(&token).to_hex(),
resumed,
connected: !servers.is_empty(),
diff --git a/crates/botloftd/src/supervisor/start.rs b/crates/botloftd/src/supervisor/start.rs
index 0da6228d..89be4ee9 100644
--- a/crates/botloftd/src/supervisor/start.rs
+++ b/crates/botloftd/src/supervisor/start.rs
@@ -127,19 +127,27 @@ impl Supervisor {
let generation = self
.next_generation
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ // The agent of a launched bot runs; it may wrap the process to hold
+ // a conversation with it (spec 30).
+ let agent = agent::of(bot.agent).unwrap_or(&ClaudeAgent);
+ let crate::runtime::Process {
+ pid,
+ events,
+ control: raw,
+ } = process;
+ let (writer, decoder) = agent.attach(raw, launch.attach.clone());
// What the session applies and how full it is (spec 9.2); Claude
// Code answers as soon as it is up.
- // The agent of a launched bot runs.
- let speaks = agent::of(bot.agent).is_some_and(|agent| agent.speaks_control());
+ let speaks = agent.speaks_control();
if speaks {
for ask in [control::settings_request(), control::context_request()] {
- let _ = process.control.write(ask);
+ let _ = writer.write(ask);
}
}
// Each connected tool says whether it came up (spec 25.5).
crate::service::mcp_state::forget(daemon, &bot.id);
if launch.connected && speaks {
- let _ = process.control.write(control::mcp_request());
+ let _ = writer.write(control::mcp_request());
}
context::process_started(daemon, &bot.id, launch.resumed);
// A new conversation is on disk only once it has a turn (spec
@@ -156,7 +164,7 @@ impl Supervisor {
slot.limited_until = None;
tokens.insert(launch.token_hash.clone(), (bot.id.clone(), generation));
slot.running = Some(Running {
- control: process.control,
+ control: writer,
started: Instant::now(),
resumed: launch.resumed,
token_hash: launch.token_hash,
@@ -166,11 +174,9 @@ impl Supervisor {
self.count_busy(slot.state, BotState::Launching);
slot.state = BotState::Launching;
self.announce(&bot.id, slot);
- debug!(bot = %bot.id, generation, pid = ?process.pid, resumed = launch.resumed, "bot started");
- // The bot launched, so its agent runs.
- let agent = agent::of(bot.agent).unwrap_or(&ClaudeAgent);
- let reader = StreamReader::new(bot.id.clone(), generation, agent);
- tokio::spawn(pump(self.daemon.clone(), reader, process.events));
+ debug!(bot = %bot.id, generation, pid = ?pid, resumed = launch.resumed, "bot started");
+ let reader = StreamReader::new(bot.id.clone(), generation, decoder);
+ tokio::spawn(pump(self.daemon.clone(), reader, events));
tokio::spawn(settle(
self.daemon.clone(),
bot.id.clone(),
diff --git a/crates/botloftd/tests/codex_bot.rs b/crates/botloftd/tests/codex_bot.rs
new file mode 100644
index 00000000..3d127ab5
--- /dev/null
+++ b/crates/botloftd/tests/codex_bot.rs
@@ -0,0 +1,404 @@
+//! A bot that runs on Codex (spec 30), on a FakeRuntime: the process speaks
+//! `codex app-server`'s JSON-RPC, and the handshake, the turn, the chat, the
+//! context and the answers to its requests come out as for the other agents.
+
+mod common;
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use botloft_core::ids::BotId;
+use botloft_core::protocol::{
+ AgentKind, BotState, BotsCreateParams, ChatBody, CrewsCreateParams, ToolStatus,
+};
+use botloftd::agent;
+use botloftd::runtime::fake::{FakeProcess, FakeRuntime};
+use botloftd::service::{bots, crews};
+use botloftd::state::Daemon;
+use botloftd::supervisor;
+use common::{new_daemon, test_settings};
+use serde_json::{Value, json};
+
+struct Codex {
+ daemon: Arc,
+ runtime: FakeRuntime,
+ bot: BotId,
+ _dir: tempfile::TempDir,
+}
+
+async fn codex_bot() -> Codex {
+ let mut settings = test_settings();
+ settings.experimental_agents = vec!["codex".to_owned()];
+ settings.codex_path = r"C:\Codex\codex.exe".to_owned();
+ let parts = new_daemon(settings);
+ let crew = crews::create(
+ &parts.daemon,
+ CrewsCreateParams {
+ name: "Ops".into(),
+ work_folder: None,
+ lead: None,
+ },
+ )
+ .expect("crew");
+ let bot = bots::create(
+ &parts.daemon,
+ BotsCreateParams {
+ crew_id: crew.id,
+ name: "Scout".into(),
+ role: String::new(),
+ instructions: "Keep notes.".into(),
+ color: None,
+ model: None,
+ agent: Some(AgentKind::Codex),
+ },
+ )
+ .expect("codex bot");
+ assert_eq!(bot.agent, AgentKind::Codex);
+ tokio::spawn(supervisor::run(Arc::clone(&parts.daemon)));
+ Codex {
+ daemon: parts.daemon,
+ runtime: parts.runtime,
+ bot: bot.id,
+ _dir: parts.dir,
+ }
+}
+
+impl Codex {
+ async fn until(&self, state: BotState) {
+ for _ in 0..600 {
+ if self.daemon.supervisor.status(&self.bot).map(|(s, _)| s) == Some(state) {
+ return;
+ }
+ tokio::time::sleep(Duration::from_millis(10)).await;
+ }
+ panic!("bot never reached {state:?}");
+ }
+
+ fn items(&self) -> Vec {
+ let mut items = self
+ .daemon
+ .store()
+ .chat_history(&self.bot, None, 50)
+ .expect("history");
+ items.reverse();
+ items.into_iter().map(|item| item.body).collect()
+ }
+
+ /// The owner's message, as the courier writes it.
+ fn message(&self, uuid: &str, text: &str) {
+ let line = agent::of(AgentKind::Codex).expect("agent").encode_turn(
+ uuid,
+ &agent::Turn {
+ text: text.into(),
+ images: Vec::new(),
+ },
+ );
+ self.daemon
+ .supervisor
+ .write_message(&self.bot, uuid, line)
+ .expect("running");
+ }
+}
+
+/// The methods the process was sent, in order.
+fn methods(process: &FakeProcess) -> Vec {
+ process
+ .input_lines()
+ .iter()
+ .map(|line| line["method"].as_str().unwrap_or("(answer)").to_owned())
+ .collect()
+}
+
+/// Waits for the process to have been sent `method`, and returns that line.
+async fn sent(process: &FakeProcess, method: &str) -> Value {
+ for _ in 0..600 {
+ if let Some(line) = process
+ .input_lines()
+ .into_iter()
+ .find(|line| line["method"] == method)
+ {
+ return line;
+ }
+ tokio::time::sleep(Duration::from_millis(10)).await;
+ }
+ panic!("never sent {method}: {:?}", methods(process));
+}
+
+/// The handshake: `initialize`, then the thread.
+async fn handshake(process: &FakeProcess, thread: &str) {
+ let init = sent(process, "initialize").await;
+ process
+ .emit(json!({ "id": init["id"], "result": { "userAgent": "codex" } }))
+ .await;
+ let start = sent(process, "thread/start").await;
+ process
+ .emit(json!({ "id": start["id"], "result": { "thread": { "id": thread }, "model": "gpt-5.5" } }))
+ .await;
+}
+
+fn notify(method: &str, params: Value) -> Value {
+ json!({ "method": method, "params": params })
+}
+
+#[tokio::test(start_paused = true)]
+async fn a_codex_bot_starts_its_server_with_the_owners_things_off_and_its_own_mcp() {
+ let codex = codex_bot().await;
+ let process = codex.runtime.process(1).await;
+ assert_eq!(
+ process.spec.program.to_string_lossy(),
+ r"C:\Codex\codex.exe"
+ );
+ let args = process.args();
+ assert_eq!(args[0], "app-server");
+ for flag in [
+ "features.apps=false",
+ "features.hooks=false",
+ "features.plugins=false",
+ "notify=[]",
+ ] {
+ assert!(args.contains(&flag.to_owned()), "{flag} in {args:?}");
+ }
+ // It answers Botloft's lines in JSON-RPC, not the control requests.
+ assert!(process.control_requests().is_empty());
+
+ handshake(&process, "th-1").await;
+ let start = sent(&process, "thread/start").await;
+ assert_eq!(start["params"]["approvalPolicy"], "never");
+ assert_eq!(start["params"]["sandbox"], "read-only");
+ let server = &start["params"]["config"]["mcp_servers"]["botloft"];
+ assert_eq!(server["url"], "http://127.0.0.1:45710/mcp");
+ assert!(
+ server["http_headers"]["Authorization"]
+ .as_str()
+ .is_some_and(|value| value.starts_with("Bearer "))
+ );
+ let workspace = process.spec.cwd.clone();
+ let rules = std::fs::read_to_string(workspace.join("AGENTS.md")).expect("AGENTS.md");
+ assert!(rules.contains("Scout") && rules.contains("Keep notes."));
+}
+
+#[tokio::test(start_paused = true)]
+async fn what_codex_prints_becomes_the_chat() {
+ let codex = codex_bot().await;
+ let process = codex.runtime.process(1).await;
+ handshake(&process, "th-1").await;
+ codex.until(BotState::Idle).await;
+
+ codex.message("u-1", "List the folder");
+ codex.until(BotState::Busy).await;
+ let turn = sent(&process, "turn/start").await;
+ assert_eq!(turn["params"]["threadId"], "th-1");
+ assert_eq!(turn["params"]["input"][0]["text"], "List the folder");
+ process
+ .emit(json!({ "id": turn["id"], "result": { "turn": { "id": "t-1", "status": "inProgress" } } }))
+ .await;
+
+ let t = "th-1";
+ process
+ .emit(notify(
+ "turn/started",
+ json!({ "threadId": t, "turn": { "id": "t-1" } }),
+ ))
+ .await;
+ process
+ .emit(notify(
+ "item/started",
+ json!({ "threadId": t, "item": { "type": "userMessage", "id": "i0" } }),
+ ))
+ .await;
+ process
+ .emit(notify(
+ "item/started",
+ json!({ "threadId": t, "item": {
+ "type": "commandExecution", "id": "c1", "command": "powershell.exe -Command 'ls'",
+ "commandActions": [{ "type": "unknown", "command": "ls" }], "status": "inProgress" } }),
+ ))
+ .await;
+ process
+ .emit(notify(
+ "item/completed",
+ json!({ "threadId": t, "item": {
+ "type": "commandExecution", "id": "c1", "status": "completed", "exitCode": 0,
+ "aggregatedOutput": "a.txt\n" } }),
+ ))
+ .await;
+ process
+ .emit(notify(
+ "item/started",
+ json!({ "threadId": t, "item": {
+ "type": "mcpToolCall", "id": "m1", "server": "botloft", "tool": "send_message",
+ "arguments": { "to": "writer" }, "status": "inProgress" } }),
+ ))
+ .await;
+ process
+ .emit(notify(
+ "item/completed",
+ json!({ "threadId": t, "item": {
+ "type": "mcpToolCall", "id": "m1", "status": "completed",
+ "result": { "content": [{ "type": "text", "text": "sent" }] } } }),
+ ))
+ .await;
+ process
+ .emit(notify(
+ "item/agentMessage/delta",
+ json!({ "threadId": t, "delta": "One " }),
+ ))
+ .await;
+ process
+ .emit(notify("item/completed", json!({ "threadId": t, "item": {
+ "type": "agentMessage", "id": "a1", "text": "One file: a.txt", "phase": "final_answer" } })))
+ .await;
+ process
+ .emit(notify("thread/tokenUsage/updated", json!({ "threadId": t, "tokenUsage": {
+ "total": { "totalTokens": 1100, "inputTokens": 1000, "cachedInputTokens": 400, "outputTokens": 80, "reasoningOutputTokens": 20 },
+ "last": { "totalTokens": 1100, "inputTokens": 1000, "cachedInputTokens": 400, "outputTokens": 80, "reasoningOutputTokens": 20 },
+ "modelContextWindow": 258400 } })))
+ .await;
+ process
+ .emit(notify(
+ "turn/completed",
+ json!({ "threadId": t, "turn": {
+ "id": "t-1", "status": "completed", "error": null, "durationMs": 2500 } }),
+ ))
+ .await;
+ codex.until(BotState::Idle).await;
+
+ let items = codex.items();
+ let replies: Vec<&str> = items
+ .iter()
+ .filter_map(|body| match body {
+ ChatBody::Reply(reply) => Some(reply.text.as_str()),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(replies, ["One file: a.txt"]);
+ let tools: Vec<(&str, ToolStatus)> = items
+ .iter()
+ .filter_map(|body| match body {
+ ChatBody::Tool(tool) => Some((tool.name.as_str(), tool.status)),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(
+ tools,
+ [
+ ("Bash", ToolStatus::Done),
+ ("mcp__botloft__send_message", ToolStatus::Done)
+ ]
+ );
+ let turn_item = items
+ .iter()
+ .find_map(|body| match body {
+ ChatBody::Turn(turn) => Some(turn),
+ _ => None,
+ })
+ .expect("a turn");
+ assert_eq!(turn_item.duration_ms, 2500);
+ assert_eq!(turn_item.error, None);
+ let tokens = turn_item.tokens.as_ref().expect("tokens");
+ assert_eq!(
+ (tokens.input, tokens.cache_read, tokens.output),
+ (600, 400, 100)
+ );
+
+ // The context is the last request's, with the model's own window.
+ let context = codex.daemon.contexts.get(&codex.bot).expect("context");
+ assert_eq!(
+ (context.used_tokens, context.window_tokens),
+ (1080, 258_400)
+ );
+ // The conversation to resume, and the model it ran on.
+ assert_eq!(
+ codex
+ .daemon
+ .store()
+ .session_id(&codex.bot)
+ .expect("read")
+ .as_deref(),
+ Some("th-1")
+ );
+ let bot = codex
+ .daemon
+ .store()
+ .bot(&codex.bot)
+ .expect("read")
+ .expect("bot");
+ assert_eq!(bot.model_in_use.as_deref(), Some("gpt-5.5"));
+}
+
+#[tokio::test(start_paused = true)]
+async fn requests_for_approval_are_declined_for_now_and_the_crew_tools_are_allowed() {
+ let codex = codex_bot().await;
+ let process = codex.runtime.process(1).await;
+ handshake(&process, "th-1").await;
+ codex.until(BotState::Idle).await;
+
+ process
+ .emit(json!({ "id": 90, "method": "item/fileChange/requestApproval", "params": { "itemId": "f1" } }))
+ .await;
+ process
+ .emit(json!({ "id": 91, "method": "mcpServer/elicitation/request", "params": { "serverName": "botloft", "threadId": "th-1" } }))
+ .await;
+ process
+ .emit(json!({ "id": 92, "method": "mcpServer/elicitation/request", "params": { "serverName": "other", "threadId": "th-1" } }))
+ .await;
+ process
+ .emit(json!({ "id": 93, "method": "item/tool/call", "params": {} }))
+ .await;
+ for _ in 0..200 {
+ if process
+ .input_lines()
+ .iter()
+ .filter(|l| l["id"].as_u64() >= Some(90))
+ .count()
+ == 4
+ {
+ break;
+ }
+ tokio::time::sleep(Duration::from_millis(10)).await;
+ }
+ let answer = |id: u64| -> Value {
+ process
+ .input_lines()
+ .into_iter()
+ .find(|line| line["id"] == id)
+ .unwrap_or_else(|| panic!("no answer to {id}"))
+ };
+ assert_eq!(answer(90)["result"]["decision"], "decline");
+ assert_eq!(answer(91)["result"]["action"], "accept");
+ assert_eq!(answer(92)["result"]["action"], "decline");
+ assert!(answer(93)["error"].is_object());
+}
+
+#[tokio::test(start_paused = true)]
+async fn the_next_start_resumes_the_thread_and_a_lost_one_starts_again() {
+ let codex = codex_bot().await;
+ let first = codex.runtime.process(1).await;
+ handshake(&first, "th-7").await;
+ codex.until(BotState::Idle).await;
+ codex.message("u-1", "hello");
+ let turn = sent(&first, "turn/start").await;
+ first.emit(json!({ "id": turn["id"], "result": {} })).await;
+ first
+ .emit(notify(
+ "item/started",
+ json!({ "threadId": "th-7", "item": { "type": "userMessage", "id": "i" } }),
+ ))
+ .await;
+ first
+ .emit(notify("turn/completed", json!({ "threadId": "th-7", "turn": { "id": "t", "status": "completed", "durationMs": 10 } })))
+ .await;
+ codex.until(BotState::Idle).await;
+
+ first.exit(1).await;
+ let second = codex.runtime.process(2).await;
+ let init = sent(&second, "initialize").await;
+ second.emit(json!({ "id": init["id"], "result": {} })).await;
+ let resume = sent(&second, "thread/resume").await;
+ assert_eq!(resume["params"]["threadId"], "th-7");
+ // Codex does not have it: a new thread takes its place.
+ second
+ .emit(json!({ "id": resume["id"], "error": { "code": -1, "message": "no rollout found" } }))
+ .await;
+ sent(&second, "thread/start").await;
+}
diff --git a/crates/botloftd/tests/common/mod.rs b/crates/botloftd/tests/common/mod.rs
index 2fed4e83..9f540a27 100644
--- a/crates/botloftd/tests/common/mod.rs
+++ b/crates/botloftd/tests/common/mod.rs
@@ -68,6 +68,7 @@ pub fn test_settings() -> SupervisorSettings {
version: "2.1.284".to_owned(),
}),
agy_path: String::new(),
+ codex_path: String::new(),
experimental_agents: Vec::new(),
backoff_initial: Duration::from_millis(40),
backoff_max: Duration::from_millis(200),
diff --git a/docs/spec.md b/docs/spec.md
index bad67687..ba5819fd 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -8,7 +8,7 @@ Status: rascunho para implementação. Este documento é a fonte de verdade do p
## 1. Objetivo e princípios
-Botloft é um workspace desktop, Windows-first e com código público (licença FSL-1.1-ALv2, em `LICENSE`), para rodar vários agentes de código persistentes que colaboram entre si. O agente de cada bot é o Claude Code, o Antigravity (experimental) ou, em seguida, o Codex (seção 30).
+Botloft é um workspace desktop, Windows-first e com código público (licença FSL-1.1-ALv2, em `LICENSE`), para rodar vários agentes de código persistentes que colaboram entre si. O agente de cada bot é o Claude Code, o Antigravity ou o Codex (os dois últimos experimentais, seção 30).
Princípios:
@@ -2650,7 +2650,9 @@ Decisão e contexto: `docs/adr/0003-agentes.md`. Um bot tem um **agente**, `Bot.
| **A0** | ADR, esta seção, `bots.agent` no banco e no protocolo; `bots.create` com `codex` ou `agy` dá `-32004` ("not available yet") | feito |
| **A1** | A interface `Agent` no daemon (`crates/botloftd/src/agent`), com o Claude por trás e os testes de hoje sem mudança. Em fatias: **A1a** argumentos do processo, ambiente extra e codificação do turno no stdin (feito); **A1b** leitura da saída: cada agente decodifica o próprio fluxo (`Agent::handle_output`, `Agent::live_text`) e grava no chat por `chat::sink` (resposta, ferramenta que começa e termina, fim de turno), o que todo agente tem; o que é só do Claude (contexto, compactação, parte do plano, rascunho de telas) fica no decodificador dele, `chat/claude.rs` (feito); **A1c** descoberta e login, arquivos da pasta do bot e aprovações | em andamento |
| **A2** | AGY, **experimental**: só existe quando `experimental_agents = ["agy"]` no `config.toml`. Lançamento, entrada e saída, MCP do Botloft, isolamento por regras `deny`, retomada de conversa, no `agy` real. Sem aprovações do dono e sem comandos de shell (30.3); sem modelo e esforço escolhidos, contexto, compactação nem ferramentas conectadas do dono | feito (sem seletor no app) |
-| **A3** | Codex por `codex app-server` | a fazer |
+| **A3a** | Codex, **experimental** (`experimental_agents = ["codex"]`): um `codex app-server` por bot, a conversa por JSON-RPC, texto ao vivo, ferramentas, MCP do Botloft por conversa, contexto exato, retomada. Sandbox `read-only` e `approvalPolicy: never`: o bot lê e responde, não muda arquivos nem roda comandos que escrevam | feito |
+| **A3b** | Codex: os pedidos de aprovação viram os cartões do Botloft (com "sempre permitir"); alterações em pastas protegidas são recusadas sozinhas | a fazer |
+| **A3c** | Codex: lista de modelos e esforço, uso do plano, imagens | a fazer |
| **A5** | **Lacunas do AGY**: comandos liberados por bot, bot AGY sobe sem o Claude Code, retomada conferida com o `agy` real (30.3, 30.4) | feito |
| **A4** | Seletor de agente no app (**feito**: na criação do bot, aparece quando há mais de um agente em `enabledAgents`, com o aviso de experimental e sem a escolha de modelo; o medidor de contexto, o esforço e o modelo do campo de mensagem só aparecem para bots Claude). **Falta:** conta e login de cada agente, paridade de rotinas, navegador, desktop e perguntas | em andamento |
@@ -2675,6 +2677,16 @@ Descoberta e saúde (binário, versão mínima, login, comando de entrar); lanç
- **Não faz:** pedidos de controle (`get_settings`, `get_context_usage`, `mcp_status`, `/compact`): o `agy` recusa o formato; por isso "Compactar" não existe para esses bots. Também não há cartão de plano, uso do plano nem ferramentas conectadas do dono.
- **Eventos:** `init` dá o modelo; `step_update` `user_input` `DONE` é o recibo de leitura da mensagem mais antiga escrita (o `agy` não devolve o id) e grava a conversa a retomar (`--conversation`); `agent_response` `ACTIVE` é o texto ao vivo e `DONE` fecha a resposta; `tool` `ACTIVE`, `DONE` e `ERROR` são a ferramenta (`write_to_file`, `run_command`, `view_file`... aparecem como `Write`, `Bash`, `Read`); `result` fecha o turno, com `usage` e `status`.
+### 30.3.2 Codex (`codex app-server`), experimental
+
+- **Ligar:** `experimental_agents = ["codex"]` no `config.toml`; `codex_path` aponta para o executável (vazio procura em `%LOCALAPPDATA%\OpenAI\Codex\bin\\codex.exe` e no PATH).
+- **Processo:** `codex app-server -c features.apps=false -c features.hooks=false -c features.plugins=false -c notify=[] -c mcp_servers..enabled=false` (um para cada MCP do `config.toml` do dono), na `CODEX_HOME` do dono: o login vale sem copiar o token. O invólucro `CodexControl` (`agent/codex_wire.rs`) recebe a linha neutra do Botloft e a vira `turn/start` quando a thread existe e nenhum turno roda; as mensagens esperam em fila.
+- **Aperto de mão:** `initialize`, `initialized`, `thread/start` (ou `thread/resume` com o id guardado; se não existir, `thread/start`). A thread leva `cwd` (a pasta do bot), `approvalPolicy: "never"`, `sandbox: "read-only"`, `model` (`Bot.agentModel`) e o servidor MCP `botloft` com o token deste processo em `http_headers`.
+- **Eventos:** `item/started` `userMessage` é o recibo de leitura da mensagem mais antiga escrita e grava a thread a retomar; `item/agentMessage/delta` é o texto ao vivo e `item/completed` `agentMessage` fecha a resposta; `commandExecution`, `fileChange` e `mcpToolCall` aparecem como `Bash`, `Write`/`Edit` e `mcp____`; `thread/tokenUsage/updated` dá o contexto (a última requisição e `modelContextWindow`) e os tokens do turno (o total da thread menos o que era no começo); `turn/completed` fecha o turno com `durationMs` e o erro.
+- **Pedidos do servidor:** `mcpServer/elicitation/request` do servidor `botloft` é aceito (o bot precisa das ferramentas da equipe), de outro servidor é recusado; pedidos de aprovação de comando e de arquivo são recusados (A3b os leva ao dono); o resto recebe um erro.
+- **Verificado com o Codex real** (0.148.0-alpha.9, daemon de desenvolvimento): o bot soube o nome e a equipe pelo `AGENTS.md`, listou as ferramentas do `botloft`, chamou `crew_roster`, e a tentativa de criar um arquivo falhou como `Bash` falho.
+- **Limites desta fatia:** não escreve arquivos nem roda comandos que escrevam; a leitura não é limitada (`read-only` lê tudo); o `AGENTS.md` global e as skills da pasta pessoal do dono continuam visíveis para ele; sem seletor de modelo (usa o padrão do Codex) e sem imagens (a mensagem lista o caminho); o aviso do app diz o que ele ainda não faz.
+
### 30.3.1 Isolamento dos agentes
Um agente só roda bots quando o Botloft impõe o que a seção 7.5 impõe no Claude: nada de ler as outras crews nem os dados do Botloft. Cada agente usa o seu mecanismo, e o resultado do teste real entra aqui antes de o agente ser liberado.
@@ -2700,4 +2712,10 @@ Um agente só roda bots quando o Botloft impõe o que a seção 7.5 impõe no Cl
| `agy models` | Lista `gemini-3.8-flash-{high,medium,low}`, `gemini-3.7-...`, `gemini-3.1-pro-{high,low}` e `claude-opus-5-5-{low,medium,high}`, entre outros: o nível de esforço faz parte do id do modelo | feito |
| `codex` 0.148.0-alpha.9: `exec --json` | Um turno por processo: `thread.started`, `turn.started`, `item.completed` (`agent_message`), `turn.completed` com `usage` (`input_tokens`, `cached_input_tokens`, `output_tokens`, `reasoning_output_tokens`). Não serve para um bot que fica ligado | feito: um turno, `read-only` |
| `codex app-server` | Experimental, JSON-RPC. `app-server generate-json-schema` gera o esquema, que traz pedidos de aprovação (`CommandExecutionRequestApprovalParams`, `FileChangeRequestApprovalParams`, `PermissionsRequestApprovalParams`, `McpServerElicitationRequestParams`) e `DynamicToolCallParams` | esquema gerado e lido; conversa **não vista** (A3) |
-| `codex`: pasta, sandbox e MCP | `-C`, `--add-dir`, `-s read-only\|workspace-write\|danger-full-access`, `codex mcp`, `-c chave=valor`, `AGENTS.md` | só o `--help` lido |
+| `codex` 0.148.0-alpha.9: `app-server` por stdio | **Testado**: `initialize` (`clientInfo`) e a notificação `initialized`; `thread/start { cwd, approvalPolicy, sandbox, config, model }` devolve `thread.id`, `model`, `sandbox`, `approvalPolicy`; `turn/start { threadId, input: [{type:"text",text}, {type:"localImage",path}] }`. Notificações: `turn/started`, `item/started` e `item/completed` (`userMessage`, `reasoning`, `agentMessage` com `text` e `phase`, `commandExecution` com `command`, `cwd` e `status`, `fileChange` com `changes[{path,kind,diff}]`, `mcpToolCall` com `server`, `tool`, `arguments`, `result`), `item/agentMessage/delta` (texto ao vivo), `turn/completed` (`status`, `durationMs`, `error`), `thread/tokenUsage/updated` (`total` e `last` com `inputTokens`, `cachedInputTokens`, `outputTokens`, `reasoningOutputTokens` e **`modelContextWindow`**: 258400 com `gpt-5.5`) e `account/rateLimits/updated` (`usedPercent`, `windowDurationMins`, `resetsAt`, `planType`) | feito: uma conversa de vários itens |
+| `codex`: aprovações | Com `sandbox: "read-only"` e `approvalPolicy: "on-request"`, uma alteração de arquivo chega como pedido do servidor `item/fileChange/requestApproval` (`itemId`; o caminho está no `item/started` do mesmo `itemId`); responder `{decision:"accept"}` aplica e `"decline"` recusa. `item/commandExecution/requestApproval` traz `command`, `cwd`, `reason`. Comandos só de leitura (`echo hello`) rodaram sem perguntar. Uma chamada a uma ferramenta MCP chega como `mcpServer/elicitation/request` com `_meta.codex_approval_kind = "mcp_tool_call"`; responder `{action:"accept", content:null}` deixa a chamada seguir | feito |
+| `codex`: sandbox do Windows | `sandbox: "workspace-write"` **travou** o comando de escrever um arquivo (`commandExecution` ficou `inProgress` por mais de 3 minutos, sem pedido de aprovação), mesmo com `windowsSandbox/readiness` em `ready` e depois de `windowsSandbox/setupStart`. `codex sandbox -- cmd.exe /c echo hi` funciona fora do app-server. `danger-full-access` roda sem perguntar. Por isso o Botloft usa `read-only` com aprovações | feito: neste computador; **não** visto fora do ambiente de teste |
+| `codex`: MCP por conversa | `thread/start { config: { mcp_servers: { botloft: { url, http_headers: { Authorization: "Bearer " } } } } } }`: o servidor viu `initialize`, `tools/list` e `tools/call` com o cabeçalho; a ferramenta voltou na resposta. O MCP do dono fica de fora com `-c mcp_servers..enabled=false` e `-c features.apps=false` (o `mcp_servers={}` **não** remove) | feito |
+| `codex`: instruções e imagem | `AGENTS.md` na pasta de trabalho foi lido (o bot disse o codinome); `localImage` deixou o bot ver uma imagem (respondeu "Blue") | feito |
+| `codex`: configuração pessoal | O app-server carrega `~/.codex/config.toml` do dono: o MCP `node_repl`, os plugins e a configuração de `notify`. `-c features.apps=false -c features.hooks=false -c features.plugins=false -c mcp_servers..enabled=false -c notify=[]` tirou todos os MCP e plugins; ficaram as skills da pasta pessoal (44 para 36). Cada pasta de trabalho nova foi **gravada em `~/.codex/config.toml`** como `[projects.'']`. `CODEX_HOME` numa pasta do bot sem o `config.toml` do dono tira tudo isso, mas leva o login junto: um `auth.json` ligado por hard link funcionou, e uma cópia arriscaria invalidar o token do dono ao renovar (não testado) | feito |
+| `codex`: retomar conversa, erros de login e de limite, `turn/steer`, compactação | **Não visto** | a fazer (A3) |