diff --git a/README.md b/README.md index d919825..ca1e24e 100644 --- a/README.md +++ b/README.md @@ -117,9 +117,10 @@ like this: | 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 and Codex are off until you list them in Botloft's -`config.toml` (`experimental_agents = ["agy"]`, `["codex"]` or both); then a new agent can be made on -them, and Settings has "New agents run on". A Codex agent asks you before it changes a file or runs a +Claude Code is the full experience. Antigravity and Codex are off until you turn on "Let me choose +Antigravity and Codex" in Settings (or list them in Botloft's `config.toml`, `experimental_agents = +["agy"]`, `["codex"]` or both); then a new agent can be made on them, and Settings has "New agents run +on". A Codex agent asks you before it changes a file or runs a command, like Claude Code, and shows the exact size of its conversation; it can still read any file. It has its own model list and effort picker, and understands images. See [spec section 30](docs/spec.md) for what was checked. diff --git a/app/src/features/account/GeneralSettings.tsx b/app/src/features/account/GeneralSettings.tsx index 054c988..e28047e 100644 --- a/app/src/features/account/GeneralSettings.tsx +++ b/app/src/features/account/GeneralSettings.tsx @@ -41,6 +41,15 @@ export function GeneralSettings() {
{ keepAwake: false, approvalWaitMinutes: 60, defaultAgent: "claude", + showExperimentalAgents: false, }), ); expect( @@ -65,6 +66,26 @@ describe("settings", () => { ).toEqual([{ startWithWindows: false }, { keepAwake: false }]); }); + test("the experimental agents are switched on here, with what that means", async () => { + const { fake } = renderApp(); + const dialog = await openSettings(); + const show = within(dialog).getByRole("switch", { + name: "Let me choose Antigravity and Codex", + }); + await waitFor(() => expect(show.hasAttribute("disabled")).toBe(false)); + expect(dialog.textContent).toContain("new agents always run on Claude Code"); + expect(within(dialog).queryByText("New agents run on")).toBeNull(); + + fireEvent.click(show); + expect(show.getAttribute("aria-checked")).toBe("true"); + expect(dialog.textContent).toContain("can read any file on this computer"); + // The choice of agent for new agents appears without waiting. + expect(await within(dialog).findByText("New agents run on")).toBeDefined(); + expect( + fake.calls.filter((call) => call.method === "settings.update").map((c) => c.params), + ).toEqual([{ showExperimentalAgents: true }]); + }); + test("the icon and the window at sign-in show only where they apply", async () => { renderApp(); const dialog = await openSettings(); diff --git a/app/src/features/account/useDaemonSettings.ts b/app/src/features/account/useDaemonSettings.ts index 08854d6..8cee1c5 100644 --- a/app/src/features/account/useDaemonSettings.ts +++ b/app/src/features/account/useDaemonSettings.ts @@ -15,6 +15,7 @@ export function useDaemonSettings(): { const failed = useT().account.settings.saveFailed; const settings = useApp((state) => state.settings); const putSettings = useApp((state) => state.putSettings); + const putSystem = useApp((state) => state.putSystem); const change = (update: SettingsUpdateParams) => { const before = settings; @@ -24,11 +25,17 @@ export function useDaemonSettings(): { keepAwake: update.keepAwake ?? before.keepAwake, approvalWaitMinutes: update.approvalWaitMinutes ?? before.approvalWaitMinutes, defaultAgent: update.defaultAgent ?? before.defaultAgent, + showExperimentalAgents: update.showExperimentalAgents ?? before.showExperimentalAgents, }); } - attempt(failed, async () => putSettings(await api.call("settings.update", update))).then( - (saved) => saved || putSettings(before), - ); + attempt(failed, async () => { + putSettings(await api.call("settings.update", update)); + // The agents to choose from follow this setting; the status has no + // notification, so it is read again now. + if (update.showExperimentalAgents !== undefined) { + putSystem(await api.call("system.status")); + } + }).then((saved) => saved || putSettings(before)); }; return { settings, change }; diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts index 47c574a..305de3a 100644 --- a/app/src/i18n/en/account.ts +++ b/app/src/i18n/en/account.ts @@ -246,6 +246,11 @@ export const account = { pages: "Parts of Settings", general: "General", defaultAgent: "New agents run on", + experimentalAgents: "Experimental agents", + showExperimental: "Let me choose Antigravity and Codex", + showExperimentalOff: "Off: new agents always run on Claude Code.", + showExperimentalOn: + "Still being tested. An agent that runs on Codex can read any file on this computer, and one that runs on Antigravity cannot ask you before it acts. Agents already made on them keep working either way.", defaultAgentHint: "Used for new agents, the chief of a new crew and the agents a template adds. Each agent keeps the engine it was made with.", chat: "Chat", diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts index ab87667..131441e 100644 --- a/app/src/i18n/es/account.ts +++ b/app/src/i18n/es/account.ts @@ -252,6 +252,11 @@ export const account: Messages["account"] = { pages: "Partes de la configuración", general: "General", defaultAgent: "Los agentes nuevos funcionan con", + experimentalAgents: "Agentes experimentales", + showExperimental: "Poder elegir Antigravity y Codex", + showExperimentalOff: "Apagado: los agentes nuevos siempre funcionan con Claude Code.", + showExperimentalOn: + "Todavía en pruebas. Un agente que funciona con Codex puede leer cualquier archivo de este equipo, y uno que funciona con Antigravity no puede preguntarte antes de actuar. Los agentes ya creados con ellos siguen funcionando igual.", defaultAgentHint: "Vale para los agentes nuevos, el jefe de un equipo nuevo y los agentes que añade una plantilla. Cada agente conserva el motor con el que se creó.", chat: "Chat", diff --git a/app/src/i18n/pt-BR/account.ts b/app/src/i18n/pt-BR/account.ts index 60a1494..99e64ad 100644 --- a/app/src/i18n/pt-BR/account.ts +++ b/app/src/i18n/pt-BR/account.ts @@ -250,6 +250,11 @@ export const account: Messages["account"] = { pages: "Partes das configurações", general: "Geral", defaultAgent: "Os agentes novos rodam em", + experimentalAgents: "Agentes experimentais", + showExperimental: "Deixar-me escolher Antigravity e Codex", + showExperimentalOff: "Desligado: os agentes novos sempre rodam no Claude Code.", + showExperimentalOn: + "Ainda em teste. Um agente que roda no Codex pode ler qualquer arquivo deste computador, e um que roda no Antigravity não consegue perguntar a você antes de agir. Os agentes que já foram feitos neles continuam funcionando de qualquer jeito.", defaultAgentHint: "Vale para agentes novos, o chefe de uma equipe nova e os agentes que um modelo de equipe adiciona. Cada agente guarda o motor com que foi criado.", chat: "Conversa", diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts index 7ca4d41..9e7e18f 100644 --- a/app/src/lib/fake.ts +++ b/app/src/lib/fake.ts @@ -79,6 +79,7 @@ export class FakeBotloft implements BotloftApi { keepAwake: true, approvalWaitMinutes: 60, defaultAgent: "claude", + showExperimentalAgents: false, }; /** Folders the owner sent to the Recycle Bin with a delete, in order. */ readonly recycled: string[] = []; @@ -279,7 +280,15 @@ export class FakeBotloft implements BotloftApi { keepAwake: change.keepAwake ?? this.settings.keepAwake, approvalWaitMinutes: change.approvalWaitMinutes ?? this.settings.approvalWaitMinutes, defaultAgent: change.defaultAgent ?? this.settings.defaultAgent, + showExperimentalAgents: + change.showExperimentalAgents ?? this.settings.showExperimentalAgents, }; + if (change.showExperimentalAgents !== undefined) { + this.system = { + ...this.system, + enabledAgents: change.showExperimentalAgents ? ["claude", "agy", "codex"] : ["claude"], + }; + } return this.settings; }, "archive.list": () => ({ diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index f46a376..a43d532 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -1870,7 +1870,12 @@ approvalWaitMinutes: number, * The agent bots are made for when nothing says otherwise: new bots, * the chief of a new crew, the bots of a template (spec 30). */ -defaultAgent: AgentKind, }; +defaultAgent: AgentKind, +/** + * The experimental agents (Antigravity and Codex) can be chosen for + * new bots. Off, bots that already use one keep working (spec 30). + */ +showExperimentalAgents: boolean, }; /** * The settings to change; the ones left out stay as they are. @@ -1879,7 +1884,7 @@ export type SettingsUpdateParams = { startWithWindows?: boolean, keepAwake?: boo /** * From 1 to [`APPROVAL_WAIT_MAX_MINUTES`]. */ -approvalWaitMinutes?: number, defaultAgent?: AgentKind, }; +approvalWaitMinutes?: number, defaultAgent?: AgentKind, showExperimentalAgents?: boolean, }; /** * When a routine runs (spec 20.2), kept as structure rather than cron text diff --git a/app/src/store/app.ts b/app/src/store/app.ts index 1a10819..0d1ac08 100644 --- a/app/src/store/app.ts +++ b/app/src/store/app.ts @@ -102,6 +102,7 @@ export interface AppState { putRoutine(routine: Routine): void; putBrowser(browser: BrowserState): void; putSettings(settings: Settings | null): void; + putSystem(system: SystemStatus): void; } export type AppStore = StoreApi; @@ -202,6 +203,7 @@ export function createAppStore(api: BotloftApi): AppStore { putRoutine: (routine) => set((state) => withRoutine(state, routine)), putBrowser: (browser) => set((state) => withBrowser(state, browser)), putSettings: (settings) => set({ settings }), + putSystem: (system) => set({ system }), putDelivery: (delivery) => set((state) => ({ deliveries: { ...state.deliveries, [delivery.messageId]: delivery } })), }; diff --git a/crates/botloft-core/src/protocol/settings.rs b/crates/botloft-core/src/protocol/settings.rs index 8fcb124..9fd00ac 100644 --- a/crates/botloft-core/src/protocol/settings.rs +++ b/crates/botloft-core/src/protocol/settings.rs @@ -21,6 +21,9 @@ pub struct Settings { /// The agent bots are made for when nothing says otherwise: new bots, /// the chief of a new crew, the bots of a template (spec 30). pub default_agent: AgentKind, + /// The experimental agents (Antigravity and Codex) can be chosen for + /// new bots. Off, bots that already use one keep working (spec 30). + pub show_experimental_agents: bool, } /// The settings to change; the ones left out stay as they are. @@ -41,6 +44,9 @@ pub struct SettingsUpdateParams { #[serde(default)] #[cfg_attr(test, ts(optional))] pub default_agent: Option, + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub show_experimental_agents: Option, } /// The longest a permission request may wait for the owner: a day. diff --git a/crates/botloftd/src/service/settings.rs b/crates/botloftd/src/service/settings.rs index 1b300af..62cae9c 100644 --- a/crates/botloftd/src/service/settings.rs +++ b/crates/botloftd/src/service/settings.rs @@ -1,7 +1,9 @@ //! `settings.get` and `settings.update` (spec 11.2): what the owner //! changes in the app's Settings. -use botloft_core::protocol::{APPROVAL_WAIT_MAX_MINUTES, Settings, SettingsUpdateParams}; +use botloft_core::protocol::{ + APPROVAL_WAIT_MAX_MINUTES, AgentKind, Settings, SettingsUpdateParams, +}; use tracing::info; use super::{ApiError, ApiResult}; @@ -38,6 +40,29 @@ pub fn update(daemon: &Daemon, params: SettingsUpdateParams) -> ApiResult before.approval_wait_minutes { daemon.supervisor.workspace_settings_changed(); } diff --git a/crates/botloftd/src/settings.rs b/crates/botloftd/src/settings.rs index f37bb1b..1d523cd 100644 --- a/crates/botloftd/src/settings.rs +++ b/crates/botloftd/src/settings.rs @@ -12,6 +12,10 @@ use tokio::sync::watch; use crate::config::Config; +/// The agents the "experimental agents" setting switches on, by their name +/// in `config.toml`. +pub const EXPERIMENTAL_AGENTS: [&str; 2] = ["agy", "codex"]; + pub struct LiveSettings { /// Where they are saved; `None` keeps them in memory (tests). file: Option, @@ -34,6 +38,7 @@ impl LiveSettings { approval_wait_minutes: minutes, // A name this build does not know is Claude Code. default_agent: config.default_agent.parse().unwrap_or(AgentKind::Claude), + show_experimental_agents: !config.experimental_agents.is_empty(), }; Self { file, @@ -91,6 +96,9 @@ impl LiveSettings { if let Some(agent) = update.default_agent { current.settings.default_agent = agent; } + if let Some(show) = update.show_experimental_agents { + current.settings.show_experimental_agents = show; + } Ok(current.settings) } @@ -125,6 +133,13 @@ fn write(file: &Path, update: SettingsUpdateParams) -> io::Result<()> { if let Some(agent) = update.default_agent { set(config.as_table_mut(), "default_agent", agent.as_str()); } + if let Some(show) = update.show_experimental_agents { + let mut names = toml_edit::Array::new(); + if show { + names.extend(EXPERIMENTAL_AGENTS); + } + set(config.as_table_mut(), "experimental_agents", names); + } if let Some(minutes) = update.approval_wait_minutes { let bots = config["bots"] .or_insert(toml_edit::table()) @@ -173,6 +188,7 @@ mod tests { keep_awake: false, approval_wait_minutes: 30, default_agent: AgentKind::Claude, + show_experimental_agents: false, } ); assert!(awake.has_changed().expect("sender") && !*awake.borrow_and_update()); @@ -190,6 +206,7 @@ mod tests { keep_awake: Some(true), approval_wait_minutes: Some(240), default_agent: None, + show_experimental_agents: None, }) .expect("save"); let text = std::fs::read_to_string(&file).expect("read"); @@ -208,11 +225,41 @@ mod tests { keep_awake: true, approval_wait_minutes: 240, default_agent: AgentKind::Claude, + show_experimental_agents: false, } ); assert_eq!(settings.approval_wait(), Duration::from_secs(4 * 3600)); } + #[test] + fn the_experimental_agents_are_listed_in_the_file_and_taken_out_again() { + let dir = tempfile::tempdir().expect("dir"); + let file = dir.path().join("config.toml"); + let settings = LiveSettings::new(Some(file.clone()), &Config::default()); + assert!(!settings.get().show_experimental_agents); + let on = SettingsUpdateParams { + show_experimental_agents: Some(true), + ..SettingsUpdateParams::default() + }; + assert!(settings.save(on).expect("save").show_experimental_agents); + let config = Config::parse(&std::fs::read_to_string(&file).expect("read")).expect("parse"); + assert_eq!(config.experimental_agents, ["agy", "codex"]); + // A daemon started now shows them. + assert!( + LiveSettings::new(None, &config) + .get() + .show_experimental_agents + ); + + let off = SettingsUpdateParams { + show_experimental_agents: Some(false), + ..SettingsUpdateParams::default() + }; + assert!(!settings.save(off).expect("save").show_experimental_agents); + let config = Config::parse(&std::fs::read_to_string(&file).expect("read")).expect("parse"); + assert!(config.experimental_agents.is_empty()); + } + #[test] fn a_new_wait_goes_in_the_bots_table() { let dir = tempfile::tempdir().expect("dir"); diff --git a/crates/botloftd/src/supervisor/mod.rs b/crates/botloftd/src/supervisor/mod.rs index 5fcca15..3531beb 100644 --- a/crates/botloftd/src/supervisor/mod.rs +++ b/crates/botloftd/src/supervisor/mod.rs @@ -48,6 +48,9 @@ pub struct Supervisor { daemon: Weak, runtime: Arc, settings: SupervisorSettings, + /// The experimental agents the owner switched on: `settings` has what + /// `config.toml` said at start, the Settings screen changes this. + experimental: Mutex>, events: broadcast::Sender, inner: Mutex, /// Generations are unique across bots and daemon restarts, so a client @@ -96,6 +99,7 @@ impl Supervisor { Self { daemon, runtime, + experimental: Mutex::new(settings.experimental_agents.clone()), settings, events, inner: Mutex::default(), @@ -132,23 +136,29 @@ 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). + /// experimental agent only when the owner switched it on, in Settings + /// or by listing it in `config.toml` (spec 30). pub fn agent_enabled(&self, kind: botloft_core::protocol::AgentKind) -> bool { use botloft_core::protocol::AgentKind; - match kind { - AgentKind::Claude => true, - AgentKind::Agy => self - .settings - .experimental_agents - .iter() - .any(|name| name == "agy"), - AgentKind::Codex => self - .settings - .experimental_agents - .iter() - .any(|name| name == "codex"), - } + let name = match kind { + AgentKind::Claude => return true, + AgentKind::Agy => "agy", + AgentKind::Codex => "codex", + }; + self.experimental + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .iter() + .any(|listed| listed == name) + } + + /// Switches the experimental agents on (the names listed) or off. Bots + /// that already exist keep their agent. + pub fn set_experimental_agents(&self, names: Vec) { + *self + .experimental + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = names; } /// The `agy_path` of the config; empty looks in the usual places. diff --git a/crates/botloftd/tests/settings.rs b/crates/botloftd/tests/settings.rs index 8ec14d5..9350716 100644 --- a/crates/botloftd/tests/settings.rs +++ b/crates/botloftd/tests/settings.rs @@ -15,7 +15,7 @@ async fn settings_are_read_and_changed_one_at_a_time() { let settings = app.call("settings.get", json!(null)).await.expect("get"); assert_eq!( settings, - json!({ "startWithWindows": true, "keepAwake": true, "approvalWaitMinutes": 0, "defaultAgent": "claude" }) + json!({ "startWithWindows": true, "keepAwake": true, "approvalWaitMinutes": 0, "defaultAgent": "claude", "showExperimentalAgents": false }) ); let changed = app @@ -24,7 +24,7 @@ async fn settings_are_read_and_changed_one_at_a_time() { .expect("update"); assert_eq!( changed, - json!({ "startWithWindows": true, "keepAwake": false, "approvalWaitMinutes": 0, "defaultAgent": "claude" }) + json!({ "startWithWindows": true, "keepAwake": false, "approvalWaitMinutes": 0, "defaultAgent": "claude", "showExperimentalAgents": false }) ); assert!(!*t.daemon.settings.keep_awake().borrow()); @@ -34,7 +34,7 @@ async fn settings_are_read_and_changed_one_at_a_time() { .expect("update"); assert_eq!( changed, - json!({ "startWithWindows": false, "keepAwake": false, "approvalWaitMinutes": 0, "defaultAgent": "claude" }) + json!({ "startWithWindows": false, "keepAwake": false, "approvalWaitMinutes": 0, "defaultAgent": "claude", "showExperimentalAgents": false }) ); assert_eq!( app.call("settings.get", json!(null)).await.expect("get"), @@ -47,3 +47,49 @@ async fn settings_are_read_and_changed_one_at_a_time() { ); } } + +#[tokio::test] +async fn the_experimental_agents_are_switched_on_and_off_in_settings() { + let t = TestDaemon::start().await; + std::fs::create_dir_all(&t.paths.home).expect("home"); + let mut app = t.session().await; + let agents = |status: serde_json::Value| status["enabledAgents"].clone(); + let status = app + .call("system.status", json!(null)) + .await + .expect("status"); + assert_eq!(agents(status), json!(["claude"])); + + let on = app + .call("settings.update", json!({ "showExperimentalAgents": true })) + .await + .expect("on"); + assert_eq!(on["showExperimentalAgents"], json!(true)); + let status = app + .call("system.status", json!(null)) + .await + .expect("status"); + assert_eq!(agents(status), json!(["claude", "agy", "codex"])); + + app.call("settings.update", json!({ "defaultAgent": "codex" })) + .await + .expect("default"); + let off = app + .call( + "settings.update", + json!({ "showExperimentalAgents": false }), + ) + .await + .expect("off"); + assert_eq!(off["showExperimentalAgents"], json!(false)); + assert_eq!( + off["defaultAgent"], + json!("claude"), + "no default that cannot be chosen" + ); + let status = app + .call("system.status", json!(null)) + .await + .expect("status"); + assert_eq!(agents(status), json!(["claude"])); +} diff --git a/docs/spec.md b/docs/spec.md index 92a5d64..1b87d87 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -145,7 +145,7 @@ port = 45710 # só 127.0.0.1 no MVP workspaces_root = "" # vazio = %USERPROFILE%\Botloft claude_path = "" # vazio = resolver pelo PATH agy_path = "" # vazio = %LOCALAPPDATA%\agy\bin e o PATH (seção 30) -experimental_agents = [] # ["agy"] liga os bots Antigravity (seção 30) +experimental_agents = [] # ["agy", "codex"] liga os agentes experimentais (seção 30); a tela de Configurações escreve isto default_agent = "claude" # o agente dos bots novos; muda nas Configurações (seção 30) start_with_windows = true # o daemon sobe quando o dono entra no Windows (14) keep_awake = true # impede suspensão enquanto houver bot busy @@ -2663,7 +2663,7 @@ Descoberta e saúde (binário, versão mínima, login, comando de entrar); lanç ### 30.3 Antigravity (`agy`), experimental -- **Ligar:** `experimental_agents = ["agy"]` no `config.toml`; sem isso, `bots.create` com `agent: "agy"` dá `-32004`. O app ainda não tem o seletor (A4). +- **Ligar:** `experimental_agents = ["agy"]` no `config.toml`, ou o interruptor "Deixar-me escolher Antigravity e Codex" em Configurações (`settings.update { showExperimentalAgents }`, que escreve `["agy", "codex"]` ou `[]` e vale na hora); sem isso, `bots.create` com `agent: "agy"` dá `-32004`. Desligar não mexe nos agentes que já existem, e o agente padrão volta para o Claude Code se era um experimental. - **Casa própria:** o processo roda com `USERPROFILE` e `HOME` em `\.botloft\agy-home`. Lá o Botloft escreve a cada start `.gemini/config/mcp_config.json` (o servidor `botloft`, com o token do bot no cabeçalho) e `.gemini/antigravity-cli/settings.json`. Assim o `GEMINI.md`, os servidores MCP e as configurações pessoais do dono ficam de fora (o login não mora ali e continua valendo). - **Regras do bot:** o texto que o Claude lê em `.claude/rules/botloft.md` é copiado para `AGENTS.md` na pasta do bot, que o `agy` carrega. - **Permissões:** em `-p` o `agy` não pergunta nada (30.4), então o Botloft decide de antemão: `allow` só `mcp(botloft/*)`; `deny` de `read_file()` e `write_file()` para cada pasta cercada (a mesma lista da seção 7.5: dados do Botloft e pastas de outras crews). Comandos de shell são negados pelo próprio `agy`, e o `result` traz `denied_actions`. O dono ainda não tem como liberar um comando. @@ -2680,7 +2680,7 @@ Descoberta e saúde (binário, versão mínima, login, comando de entrar); lanç ### 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). +- **Ligar:** como o AGY (interruptor em Configurações ou `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.