diff --git a/crates/kernel/README.md b/crates/kernel/README.md index 1e56575d..f56ac7a4 100644 --- a/crates/kernel/README.md +++ b/crates/kernel/README.md @@ -167,11 +167,17 @@ file tools (octos fences them to the session's working directory), memory, `ask_user_question`, media viewing, octos's `web_search` / `web_fetch` (until toolbox grants replace them, #108) and `tool_search`. Granted toolbox and cross-app tools join it as host tools through `SystemAgentTools`. Command -execution is **planned, not available**: it is to be a host tool with a live -approval (`terminal.run`), off by default and turned on by a Settings -switch. `SystemAgentTools::grant_command_execution` is the seam, but no -Settings switch sets it yet, and the host tool cannot be registered on the -system session until octos can register tools per session (octos#2567). +execution is **done** as a grant: the person's switch in Setup → Assistant → +Command execution (off by default; turning it on needs the confirmation the +person types, which says what it risks; `crates/shell/src/system_chat/grants.rs`) +gives the system agent the host tool `terminal.run` through +`SystemAgentTools::grant_command_execution`. The shell hands the grants to +this crate (`system_tools::set_grants`); each kernel start takes them +(`grants_at_start`, `system_agent_tools_in_effect()`), so a change applies +after a restart, which Settings offers. Each command goes through the shell's +approval router as `auto_approvable: false` with a live sheet showing the exact +command (developer mode still answers it). Registering the host tool on the +system session waits for octos's per-session tool registration (octos#2567). **What the kernel enforces today.** octos has no tool list the host can set for one session, so every start writes the `_main` profile's `tool_policy` diff --git a/crates/kernel/README.zh-CN.md b/crates/kernel/README.zh-CN.md index 773bcda9..8bee1277 100644 --- a/crates/kernel/README.zh-CN.md +++ b/crates/kernel/README.zh-CN.md @@ -136,9 +136,11 @@ adb -s SERIAL forward tcp:PORT tcp:PORT [ADR 0004](../../docs/adr/0004-native-apps-hosting-and-peers.md) §12:系统智能体的工具集就是它获得的授权。它默认的 octos 工具是 `system_tools::SYSTEM_AGENT_TOOLS`:监督(`peer_send_input`、`peer_gather`、`peer_list`、`peer_respond`、 `peer_close`)、其工作区内的文件工具(octos 将其限制在会话工作目录内)、记忆、`ask_user_question`、查看媒体、octos 的 -`web_search` / `web_fetch`(在工具箱授权取代它们之前,#108)以及 `tool_search`。授予的工具箱工具和跨应用工具通过 `SystemAgentTools` 作为宿主工具加入。命令执行**尚在计划中,目前不可用**: -它将是需要实时批准的宿主工具(`terminal.run`),默认关闭,由设置中的开关开启。`SystemAgentTools::grant_command_execution` -是预留接口,但目前还没有设置开关调用它,而且在 octos 支持按会话注册工具(octos#2567)之前,无法在系统会话上注册该宿主工具。 +`web_search` / `web_fetch`(在工具箱授权取代它们之前,#108)以及 `tool_search`。授予的工具箱工具和跨应用工具通过 `SystemAgentTools` 作为宿主工具加入。命令执行作为授权**已完成**:用户在“设置 → 助手 → 命令执行”中的开关(默认关闭;开启需要用户输入确认语,确认语说明其风险; +`crates/shell/src/system_chat/grants.rs`)通过 `SystemAgentTools::grant_command_execution` 为系统代理授予宿主工具 `terminal.run`。 +Shell 把授权交给本 crate(`system_tools::set_grants`);每次内核启动时采用(`grants_at_start`、`system_agent_tools_in_effect()`), +因此更改在重启后生效,设置中提供重启按钮。每条命令都经过 Shell 的批准路由器,按 `auto_approvable: false` 处理,并在实时批准表单上显示完整命令(开发者模式仍可直接批准)。 +在 octos 支持按会话注册工具(octos#2567)之前,该宿主工具尚无法注册到系统会话上。 **内核今天执行的内容。** octos 没有宿主可为单个会话设置的工具名单,因此每次启动都写入 `_main` profile 的 `tool_policy` (`system_tools::tool_policy`):任何授权可给予的一切,唯独去掉 octos 自己的 shell(`group:runtime`:`shell`、`bash`、 diff --git a/crates/kernel/src/launch.rs b/crates/kernel/src/launch.rs index af7ff47d..46a6316f 100644 --- a/crates/kernel/src/launch.rs +++ b/crates/kernel/src/launch.rs @@ -221,6 +221,9 @@ pub(crate) fn prepare(launch: &Launch, core_dir: &Path) { // Every start: the system agent's tool set (ADR 0004 §12) as the // profile's tool policy, which octos reads at start. crate::system_tools::enforce(core_dir); + // ... and the grants it starts with (a Settings change applies from the + // next start: the shell offers a restart). + crate::system_tools::take_grants_for_start(); } /// Floor for `memory.max_inject_tokens` in the phone kernel's config. diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index cfa48e45..eab725cd 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -792,6 +792,16 @@ pub fn status() -> Status { global().status() } +/// The system agent's grants the running kernel started with, or `None` +/// when no kernel runs (then the next start takes +/// [`system_tools::grants`]). +pub fn system_agent_tools_in_effect() -> Option { + if !status().running { + return None; + } + system_tools::grants_at_start() +} + /// `/profiles/_main.json` of the process's kernel. pub fn profile() -> Option { core_dir().map(|d| profile_path(&d)) diff --git a/crates/kernel/src/system_tools.rs b/crates/kernel/src/system_tools.rs index 06a024d6..b3a0df42 100644 --- a/crates/kernel/src/system_tools.rs +++ b/crates/kernel/src/system_tools.rs @@ -180,9 +180,11 @@ impl SystemAgentTools { /// the host tool [`COMMAND_EXECUTION_TOOL`], each command approved live /// (section 8); never octos's shell. /// - /// TODO(ADR 0004 plan steps 4/6): persist the switch in Settings → - /// Assistant (no Settings plumbing for it exists yet) and register the - /// host tool on the system session once octos can (octos#2567 item 6). + /// The shell persists the switch (Setup → Assistant → Command + /// execution, `crates/shell/src/system_chat/grants.rs`) and hands the + /// set to [`set_grants`]; a kernel start takes it ([`grants_at_start`]). + /// TODO(ADR 0004 plan step 6): register the host tool on the system + /// session once octos can (octos#2567 item 6). pub fn grant_command_execution(&mut self, on: bool) -> &mut Self { self.command_execution = on; self @@ -209,6 +211,44 @@ impl SystemAgentTools { } } +// ---- the grants a kernel starts with --------------------------------------- + +/// The person's current grants for the system agent (the shell sets them +/// from Settings), and the grants the running kernel generation started +/// with: a change applies from the next kernel start. +static GRANTS: std::sync::Mutex> = std::sync::Mutex::new(None); +static AT_START: std::sync::Mutex> = std::sync::Mutex::new(None); + +fn lock(m: &std::sync::Mutex) -> std::sync::MutexGuard<'_, T> { + m.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// The shell hands over the person's grants (at startup, and whenever a +/// Settings switch changes). Only the shell calls this, from its Settings +/// handler, which requires the person's gesture to turn a grant on. +pub fn set_grants(tools: SystemAgentTools) { + *lock(&GRANTS) = Some(tools); +} + +/// The person's current grants ([`SystemAgentTools::new`] until the shell +/// sets them). +pub fn grants() -> SystemAgentTools { + lock(&GRANTS).clone().unwrap_or_default() +} + +/// Called before every kernel start (`launch::prepare`): the new +/// generation runs with the grants of this moment. +pub(crate) fn take_grants_for_start() { + *lock(&AT_START) = Some(grants()); +} + +/// The grants the last kernel start took; `None` before any start. The +/// shell compares it with [`grants`] to say "restart the assistant to +/// apply" (see `crate::system_agent_tools_in_effect`). +pub fn grants_at_start() -> Option { + lock(&AT_START).clone() +} + /// The octos `ToolPolicy` the kernel runs `_main` with: everything a grant /// can give (an empty allowlist is octos's "allow all") except octos's /// shell, marked as OctoSense's. @@ -325,6 +365,20 @@ mod tests { assert!(!granted.names().contains("shell"), "never octos's shell"); } + #[test] + fn a_start_takes_the_grants_of_that_moment() { + let mut on = SystemAgentTools::new(); + on.grant_command_execution(true); + set_grants(on.clone()); + take_grants_for_start(); + assert_eq!(grants_at_start(), Some(on)); + // A later change waits for the next start. + set_grants(SystemAgentTools::new()); + assert!(grants_at_start().unwrap().command_execution()); + take_grants_for_start(); + assert!(!grants_at_start().unwrap().command_execution()); + } + #[test] fn enforce_writes_ours_keeps_the_rest_and_replaces_only_our_own() { let dir = tmp("keep"); diff --git a/crates/shell/build.rs b/crates/shell/build.rs index cd228bba..4d51c035 100644 --- a/crates/shell/build.rs +++ b/crates/shell/build.rs @@ -30,6 +30,14 @@ fn main() { if feature || target_os == "android" || target_env == "ohos" { println!("cargo:rustc-cfg=mobile_only"); } + // `kernel`: this build hosts the octos kernel, exactly as + // octosense-ai-host's build.rs decides it (feature `octos-core`, or a + // native mobile target): `octosense_ai_host::kernel` exists. The system + // chat (src/system_chat/) reads it. + println!("cargo:rustc-check-cfg=cfg(kernel)"); + if native_mobile || std::env::var_os("CARGO_FEATURE_OCTOS_CORE").is_some() { + println!("cargo:rustc-cfg=kernel"); + } // `makepad_vulkan`: Makepad renders with Vulkan (`MAKEPAD=vulkan`, the // same switch makepad-platform's build reads), so a Linux desktop can // share a process app's frames zero-copy (native-apps.json's diff --git a/crates/shell/src/approvals/mod.rs b/crates/shell/src/approvals/mod.rs index f43cc450..1e9ca293 100644 --- a/crates/shell/src/approvals/mod.rs +++ b/crates/shell/src/approvals/mod.rs @@ -66,6 +66,9 @@ pub struct Approvals { /// Decisions for the peer link's calls (`peerlink:` ids), drained by /// `peer_link` ([`take_peer_decisions`]). peer: RecordingRelay, + /// Decisions for the system chat's approvals (`syschat:` ids), drained + /// by the chat ([`take_system_chat_decisions`]). + system_chat: RecordingRelay, external: Arc>>>, } @@ -74,6 +77,7 @@ pub struct Approvals { struct Dispatch { bus: RecordingRelay, peer: RecordingRelay, + system_chat: RecordingRelay, queue: RecordingRelay, external: Arc>>>, } @@ -86,6 +90,9 @@ impl ApprovalRelay for Dispatch { if id.0.starts_with(crate::peer_link::link::HELD_PREFIX) { return self.peer.approval_decided(id, decision, reason); } + if id.0.starts_with(crate::system_chat::HELD_PREFIX) { + return self.system_chat.approval_decided(id, decision, reason); + } match self.external.lock().unwrap_or_else(|e| e.into_inner()).as_mut() { Some(relay) => relay.approval_decided(id, decision, reason), None => self.queue.approval_decided(id, decision, reason), @@ -105,10 +112,11 @@ impl Approvals { let queue = RecordingRelay::default(); let bus = RecordingRelay::default(); let peer = RecordingRelay::default(); + let system_chat = RecordingRelay::default(); let external = Arc::new(Mutex::new(None)); - let dispatch = Dispatch { bus: bus.clone(), peer: peer.clone(), queue: queue.clone(), external: external.clone() }; + let dispatch = Dispatch { bus: bus.clone(), peer: peer.clone(), system_chat: system_chat.clone(), queue: queue.clone(), external: external.clone() }; let router = Router::new(rules, audit, Box::new(dev_hooks::ShellDevMode), contacts, Box::new(dispatch)); - Approvals { router, consent, settings_open: false, queue, bus, peer, external } + Approvals { router, consent, settings_open: false, queue, bus, peer, system_chat, external } } /// Sheets, rules, consent and the page: one number for "redraw". pub fn generation(&self) -> u64 { @@ -187,6 +195,12 @@ pub fn take_peer_decisions() -> Vec<(RequestId, Decision, String)> { with(|a| a.peer.take()).unwrap_or_default() } +/// The system chat's approvals (`crate::system_chat`): the router's +/// decisions, which the chat alone sends to the kernel. +pub fn take_system_chat_decisions() -> Vec<(RequestId, Decision, String)> { + with(|a| a.system_chat.take()).unwrap_or_default() +} + /// An app module registers its own confirmation sheet (`confirm: app`). pub fn register_app_confirm(app: &str, handler: Box) { with(|a| a.router.register_app_confirm(app, handler)); diff --git a/crates/shell/src/approvals/tests.rs b/crates/shell/src/approvals/tests.rs index 78760460..6e6ff5a7 100644 --- a/crates/shell/src/approvals/tests.rs +++ b/crates/shell/src/approvals/tests.rs @@ -610,7 +610,7 @@ fn only_settings_and_the_sheet_make_a_person_gesture() { let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let mut files = Vec::new(); walk(&src, &mut files); - let allowed = ["approvals/view.rs", "approvals/settings_page.rs", "approvals/rules.rs", "approvals/tests.rs"]; + let allowed = ["approvals/view.rs", "approvals/settings_page.rs", "approvals/rules.rs", "approvals/tests.rs", "system_chat/tests.rs"]; for f in files { let text = std::fs::read_to_string(&f).unwrap(); let rel = f.strip_prefix(&src).unwrap().to_string_lossy().replace('\\', "/"); diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index 770a6f55..dc99cfc6 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -63,6 +63,7 @@ pub mod ext; pub mod glance; pub mod glance_card; pub mod glance_panel; +pub mod system_chat; pub use octosense_ai_host as ai_host; // A package's `main.rs` is `octosense_main!()` (below); what only it does @@ -210,6 +211,10 @@ script_mod! { // The approval surface (approvals/): the shell's // approval and first-use sheets, the time-box // indicator, and Settings > Assistant > Approvals. + // The system chat (system_chat/): the person's + // conversation with the system agent, F8. Under the + // approval sheets, which answer its approvals. + shell_system_chat := ShellSystemChat{} shell_approvals := ShellApprovals{} shell_approvals_settings := ShellApprovalsSettings{} // Developer mode's banner (dev_mode.rs): over @@ -368,6 +373,9 @@ pub struct App { /// The approvals' generation last drawn (approvals/). #[rust] pub approvals_generation: u64, + /// The system chat's generation the surfaces last drew (system_chat/). + #[rust] + pub system_chat_generation: u64, #[rust] pub hub: Option, #[rust] @@ -3044,6 +3052,16 @@ impl App { /// What a menu row does. The ids are the jsonc's dotted paths, with /// `apps.` and `style.theme[.import].` from the providers. fn shell_menu_activate(&mut self, cx: &mut Cx, target: &str) { + if target == shell::menu::SYSTEM_CHAT_ROW { + self.close_shell_menu(cx); + system_chat::open(); + self.system_chat_changed(cx); + return; + } + if target.starts_with(shell::menu::COMMANDS_ROW) || target == shell::menu::ASSISTANT_RESTART_ROW { + self.assistant_commands_activate(cx, target); + return; + } if target == shell::menu::APPROVALS_ROW { self.close_shell_menu(cx); approvals::open_settings(); @@ -3174,6 +3192,73 @@ impl App { approvals::tick(); peer_link::tick(); self.approvals_changed(cx); + self.system_chat_changed(cx); + } + + /// The system chat moved (a frame from the kernel, the router decided + /// one of its approvals): hand approvals on, and redraw. + fn system_chat_changed(&mut self, cx: &mut Cx) { + system_chat::pump(); + let generation = system_chat::generation(); + if generation != self.system_chat_generation { + self.system_chat_generation = generation; + self.redraw_all(cx); + } + } + + /// The system chat's pane owns the pointer inside its rect while open. + fn system_chat_pointer(&mut self, cx: &mut Cx, event: &Event) -> bool { + if !system_chat::is_open() { + return false; + } + let pane = self.ui.widget(cx, ids!(shell_system_chat)); + let outcome = pane.borrow_mut::().map(|mut p| p.pointer(cx, event)).unwrap_or(system_chat::view::Outcome::Ignored); + match outcome { + system_chat::view::Outcome::Ignored => false, + system_chat::view::Outcome::Taken => { + self.system_chat_changed(cx); + true + } + system_chat::view::Outcome::OpenProviders => { + system_chat::close(); + self.launch_app(cx, "ai-providers"); + self.system_chat_changed(cx); + true + } + } + } + + /// Setup > Assistant > Command execution (`setup.assistant.commands.*`, + /// shell/menu.rs). The one place outside system_chat/ that turns the + /// system agent's command execution on: the person typed the + /// confirmation into the menu and chose Allow. + fn assistant_commands_activate(&mut self, cx: &mut Cx, target: &str) { + self.close_shell_menu(cx); + if let Some(typed) = target.strip_prefix(shell::menu::COMMANDS_ALLOW_ROW).and_then(|t| t.strip_prefix(':')) { + match system_chat::grants::CommandGesture::settings_phrase(typed) { + None => self.notify( + cx, + "The assistant may not run commands", + &format!("{} To allow it, type \u{201c}{}\u{201d} in Command execution, then choose Allow.", system_chat::grants::RISK, system_chat::grants::CONFIRM_PHRASE), + ), + Some(gesture) => match system_chat::grants::set_command_execution(true, Some(gesture)) { + Ok(()) => self.notify(cx, "Command execution is on", "Each command asks you on a sheet first. Restart the assistant to apply."), + Err(why) => self.notify(cx, "Command execution is off", &why), + }, + } + } else if target == shell::menu::COMMANDS_OFF_ROW { + match system_chat::grants::set_command_execution(false, None) { + Err(why) => self.notify(cx, "Command execution", &why), + Ok(()) => self.notify(cx, "Command execution is off", "Restart the assistant to apply."), + } + } else if target == shell::menu::ASSISTANT_RESTART_ROW { + if system_chat::grants::restart_assistant() { + self.notify(cx, "Assistant restarting", "Its conversation resumes when it is back."); + } else { + self.notify(cx, "Assistant", "It isn't running; the change applies when it starts."); + } + } + self.redraw_all(cx); } fn approvals_changed(&mut self, cx: &mut Cx) { @@ -4298,6 +4383,13 @@ impl App { } // approval-sheet, approval-batch, approval-consent, // approvals-settings (approvals/mod.rs). + // system-chat, system-chat-send: (system_chat/). + if system_chat::test_action(name) { + log!("wm: --test-action {}", name); + self.system_chat_changed(cx); + i += 2; + continue; + } if approvals::test_action(name) { log!("wm: --test-action {}", name); self.redraw_all(cx); @@ -4645,6 +4737,9 @@ impl MatchEvent for App { // Approvals (ADR 0004 §8, §4): this home's standing rules, consent // and audit, before any app can ask for an approval. approvals::init(&octosense::paths::home()); + // The system agent's grants (Setup > Assistant > Command execution), + // handed to the kernel before it first starts. + system_chat::init(std::path::Path::new(&octosense::paths::home())); // CLI: --import-theme pulls an omarchy theme and converts // it to splash before the desktop appears. let mut args = std::env::args(); @@ -5107,6 +5202,7 @@ impl App { glance_card::script_mod(vm); glance_panel::script_mod(vm); approvals::script_mod(vm); + system_chat::script_mod(vm); desktop::script_mod(vm); snap::script_mod(vm); mobile_surface::script_mod(vm); @@ -5218,7 +5314,8 @@ impl App { && (self.dev_banner_pointer(cx, event) || self.shell_menu_pointer(cx, event) || self.shell_panel_pointer(cx, event) - || self.shell_glance_pointer(cx, event)) + || self.shell_glance_pointer(cx, event) + || self.system_chat_pointer(cx, event)) { return; } @@ -5311,6 +5408,19 @@ impl App { self.alt_armed = false; return; } + // The system chat's prompt, while its pane is open. + if system_chat::key(e) { + self.alt_armed = false; + self.system_chat_changed(cx); + return; + } + // F8: the system chat, everywhere. + if e.key_code == KeyCode::F8 && bare_key(&e.modifiers) { + self.alt_armed = false; + system_chat::toggle(); + self.system_chat_changed(cx); + return; + } if self.phone_key(cx,e) {return;} // F10 is the assistant, everywhere (aicontrol decision 8): // under the WM the bare key opens the pane for whatever is @@ -5382,7 +5492,17 @@ impl App { self.pump_warm(cx); } } + // The phone's input method types into the system chat's prompt. + if let Event::TextInput(t) = event { + if cfg!(native_mobile) && self.state.is_some() && system_chat::text_input(&t.input) { + self.system_chat_changed(cx); + return; + } + } if let Event::Signal = event { + if self.state.is_some() { + self.system_chat_changed(cx); + } // A card was published, replaced or withdrawn (glance.rs). if glance::generation() != self.glance_generation && self.state.is_some() { self.glance_generation = glance::generation(); diff --git a/crates/shell/src/shell/menu.rs b/crates/shell/src/shell/menu.rs index 81a1aa71..9e227411 100644 --- a/crates/shell/src/shell/menu.rs +++ b/crates/shell/src/shell/menu.rs @@ -769,6 +769,9 @@ impl MenuModel { // The typed confirmation travels with the row (lib.rs // `developer_options_activate` checks it). DEVELOPER_ON => format!("{DEVELOPER_ON}:{}", self.filter), + // So does the command-execution confirmation (lib.rs + // `assistant_commands_activate` checks it). + COMMANDS_ALLOW_ROW => format!("{COMMANDS_ALLOW_ROW}:{}", self.filter), "workspace.files" => "apps.files".into(), "workspace.tools.terminal" => "apps.terminal".into(), "workspace.tools.task" => "apps.task".into(), @@ -785,7 +788,18 @@ const DEVELOPER_ON: &str = "setup.developer.on"; /// The Approvals page's row (lib.rs opens `approvals::open_settings`). pub const APPROVALS_ROW: &str = "setup.assistant.approvals"; -/// Settings (Setup) → Assistant → Approvals. +/// The system chat's row (lib.rs opens `system_chat::open`). +pub const SYSTEM_CHAT_ROW: &str = "setup.assistant.chat"; +/// Setup → Assistant → Command execution (system_chat/grants.rs). +pub const COMMANDS_ROW: &str = "setup.assistant.commands"; +/// Its Allow row: carries what the person typed (the confirmation). +pub const COMMANDS_ALLOW_ROW: &str = "setup.assistant.commands.allow"; +pub const COMMANDS_OFF_ROW: &str = "setup.assistant.commands.off"; +/// Restart the assistant so a changed grant applies. +pub const ASSISTANT_RESTART_ROW: &str = "setup.assistant.restart"; + +/// Settings (Setup) → Assistant: the system chat, Approvals, and Command +/// execution. fn assistant_items(existing: &[MenuItem]) -> Vec { let mut items = Vec::new(); if !existing.iter().any(|item| item.id == "setup") { @@ -798,6 +812,46 @@ fn assistant_items(existing: &[MenuItem]) -> Vec { .aliases(&["approvals", "rules", "consent"]) .describe("Standing rules, app agents and recent automatic approvals"), ); + items.push( + MenuItem::new(SYSTEM_CHAT_ROW, "Assistant chat", MenuKind::Action) + .icon(Ico::Cpu) + .aliases(&["chat", "system agent", "assistant chat", "ask"]) + .describe("Talk to the system agent (F8)"), + ); + items.extend(command_items()); + items +} + +/// Setup → Assistant → Command execution: off by default; turning it on +/// needs the confirmation typed into the menu's filter (the row carries it +/// as an alias, so typing keeps it listed) and says what it risks. +fn command_items() -> Vec { + use crate::system_chat::grants; + let on = grants::command_execution(); + let mut items = vec![MenuItem::new(COMMANDS_ROW, "Command execution", MenuKind::Menu) + .icon(Ico::Keyboard) + .aliases(&["commands", "terminal", "run commands"]) + .describe(if on { "On: each command asks you first" } else { "Off: the assistant runs no commands" })]; + items.push(MenuItem::new(&format!("{COMMANDS_ROW}.risk"), "What this allows", MenuKind::Inert).describe(grants::RISK)); + if on { + items.push(MenuItem::new(COMMANDS_OFF_ROW, "Turn off command execution", MenuKind::Action).icon(Ico::Close).describe("The assistant runs no commands")); + } else { + items.push( + MenuItem::new(COMMANDS_ALLOW_ROW, "Allow the assistant to run commands", MenuKind::Action) + .icon(Ico::Check) + .aliases(&[grants::CONFIRM_PHRASE]) + .describe(&format!("Type \u{201c}{}\u{201d}, then choose this. Each command asks you on a sheet", grants::CONFIRM_PHRASE)), + ); + } + match grants::applied() { + grants::Applied::NeedsRestart => items.push( + MenuItem::new(ASSISTANT_RESTART_ROW, "Restart the assistant to apply", MenuKind::Action) + .icon(Ico::Refresh) + .describe("The running assistant started with the other setting"), + ), + grants::Applied::NextStart => items.push(MenuItem::new(&format!("{COMMANDS_ROW}.applies"), "Applies when the assistant starts", MenuKind::Inert)), + grants::Applied::Yes => items.push(MenuItem::new(&format!("{COMMANDS_ROW}.applies"), "In effect", MenuKind::Inert)), + } items } @@ -1716,6 +1770,32 @@ mod tests { assert_eq!(model.activate().as_deref(), Some("setup.developer.on:turn on developer mode")); } + /// Setup → Assistant → Command execution: Allow carries the typed + /// confirmation to the shell (which checks it; lib.rs + /// `assistant_commands_activate`), and the chat row opens the chat. + #[test] + fn setup_assistant_offers_the_chat_and_the_command_switch() { + let mut model = MenuModel::default(); + model.open_at("", MenuSkin::Menu); + model.sel = model.rows.iter().position(|r| r.target == "setup").expect("Setup is listed"); + assert_eq!(model.activate(), None); + model.sel = model.rows.iter().position(|r| r.target == "setup.assistant").expect("Assistant"); + assert_eq!(model.activate(), None); + model.sel = model.rows.iter().position(|r| r.target == SYSTEM_CHAT_ROW).expect("Assistant chat"); + assert_eq!(model.activate().as_deref(), Some(SYSTEM_CHAT_ROW)); + model.sel = model.rows.iter().position(|r| r.target == COMMANDS_ROW).expect("Command execution"); + assert_eq!(model.activate(), None); + model.sel = model.rows.iter().position(|r| r.target == COMMANDS_ALLOW_ROW).expect("Allow"); + assert_eq!(model.activate().as_deref(), Some("setup.assistant.commands.allow:"), "chosen without the phrase"); + for ch in crate::system_chat::grants::CONFIRM_PHRASE.chars() { + model.filter.push(ch); + model.rebuild(); + assert!(model.rows.iter().any(|r| r.target == COMMANDS_ALLOW_ROW)); + } + model.sel = model.rows.iter().position(|r| r.target == COMMANDS_ALLOW_ROW).unwrap(); + assert_eq!(model.activate().as_deref(), Some("setup.assistant.commands.allow:let the assistant run commands")); + } + /// Setup → Assistant → Approvals opens the Approvals page. #[test] fn setup_assistant_offers_the_approvals_page() { diff --git a/crates/shell/src/system_chat/grants.rs b/crates/shell/src/system_chat/grants.rs new file mode 100644 index 00000000..80ebb1f5 --- /dev/null +++ b/crates/shell/src/system_chat/grants.rs @@ -0,0 +1,224 @@ +//! Setup → Assistant → Command execution: the person's switch for the +//! system agent's command execution (ADR 0004 §12; off by default). +//! +//! - **Only the person turns it on.** [`set`] needs a [`CommandGesture`], +//! which only the Settings row makes, and only from the confirmation the +//! person typed ([`CONFIRM_PHRASE`], shown with the risk: [`RISK`]), the +//! pattern of developer mode's `PersonGesture` (#118). No agent, app, bus +//! call or host service can make one (a test scans the sources). Turning +//! it OFF is always allowed. +//! - **What it grants**: the host tool `terminal.run` +//! (`octosense_kernel::system_tools::COMMAND_EXECUTION_TOOL`), never +//! octos's own shell. Each command goes through the approval router as a +//! command (`auto_approvable: false`: no standing rule answers it) with a +//! live sheet showing the exact command ([`request_command`]); developer +//! mode still answers it (§13). +//! - **When it applies**: the kernel takes the grants when it starts +//! (`octosense_kernel::system_tools::set_grants`, taken at the next +//! start), so a change says "restart the assistant to apply" and Settings +//! offers the restart. +//! - **Persisted** per OctoSense home in [`GRANTS_FILE`], owner-only. +//! +//! Registering `terminal.run` on the system session needs octos's +//! session-targeted host tools (octos#2567 item 6). Until then the grant is +//! the tool set the shell hands the kernel, and [`request_command`] is the +//! path each call will take. + +use crate::approvals::{self, Caller, RequestContext, Route, ToolSpec, Trigger}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +/// Relative to the OctoSense home. +pub const GRANTS_FILE: &str = "assistant/system-agent.json"; +/// What the person types in Settings to turn command execution on. +pub const CONFIRM_PHRASE: &str = "let the assistant run commands"; +/// The confirmation text: what the switch risks. +pub const RISK: &str = "The assistant could run any command on this computer as you: read any file (your secrets too), reach any website, and change or delete anything. You approve each command on a sheet that shows it exactly; nothing runs without that."; +/// The host tool command execution is granted as. +pub const COMMAND_TOOL: &str = "terminal.run"; +/// The app that owns the command tool (the Terminal). +pub const COMMAND_APP: &str = "terminal"; + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Grants { + #[serde(default)] + pub command_execution: bool, +} + +/// Proof that the person typed the confirmation into Settings. Only +/// [`CommandGesture::settings_phrase`] makes one, and only the shell's +/// Settings row calls it. +pub struct CommandGesture(()); + +impl CommandGesture { + /// The person typed [`CONFIRM_PHRASE`] (case and spacing ignored). + pub(crate) fn settings_phrase(typed: &str) -> Option { + let typed = typed.split_whitespace().collect::>().join(" ").to_lowercase(); + (typed == CONFIRM_PHRASE).then_some(CommandGesture(())) + } +} + +pub struct GrantStore { + path: Option, + grants: Grants, +} + +impl GrantStore { + pub fn in_home(home: &Path) -> GrantStore { + let path = home.join(GRANTS_FILE); + let grants = std::fs::read(&path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default(); + GrantStore { path: Some(path), grants } + } + pub fn memory() -> GrantStore { + GrantStore { path: None, grants: Grants::default() } + } + pub fn grants(&self) -> &Grants { + &self.grants + } + /// Turn command execution on (with the person's gesture) or off. + pub fn set_command_execution(&mut self, on: bool, gesture: Option) -> Result<(), String> { + if on && gesture.is_none() { + return Err(format!("To let the assistant run commands, type \u{201c}{CONFIRM_PHRASE}\u{201d} in Setup \u{203a} Assistant \u{203a} Command execution, then choose Allow.")); + } + self.grants.command_execution = on; + if let Some(path) = &self.path { + let body = serde_json::to_vec_pretty(&self.grants).map_err(|e| e.to_string())?; + approvals::write_private(path, &body).map_err(|e| format!("could not save {}: {e}", path.display()))?; + } + Ok(()) + } + /// The system agent's tool set these grants give. + pub fn tools(&self) -> SystemTools { + SystemTools::from(&self.grants) + } +} + +/// The system agent's tools as the kernel crate sees them. +#[cfg(kernel)] +pub type SystemTools = octosense_ai_host::kernel::system_tools::SystemAgentTools; + +#[cfg(kernel)] +impl From<&Grants> for SystemTools { + fn from(g: &Grants) -> Self { + let mut tools = SystemTools::new(); + tools.grant_command_execution(g.command_execution); + tools + } +} + +/// Without a kernel: the host tools the grants would give. +#[cfg(not(kernel))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SystemTools { + command_execution: bool, +} + +#[cfg(not(kernel))] +impl SystemTools { + pub fn command_execution(&self) -> bool { + self.command_execution + } + pub fn host_tools(&self) -> std::collections::BTreeSet { + self.command_execution.then(|| COMMAND_TOOL.to_string()).into_iter().collect() + } +} + +#[cfg(not(kernel))] +impl From<&Grants> for SystemTools { + fn from(g: &Grants) -> Self { + SystemTools { command_execution: g.command_execution } + } +} + +static STORE: Mutex> = Mutex::new(None); + +fn with(f: impl FnOnce(&mut GrantStore) -> R) -> R { + let mut guard = STORE.lock().unwrap_or_else(|e| e.into_inner()); + f(guard.get_or_insert_with(GrantStore::memory)) +} + +/// At startup: this home's grants, handed to the kernel for its starts. +pub fn init(home: &Path) { + let store = GrantStore::in_home(home); + publish(&store); + *STORE.lock().unwrap_or_else(|e| e.into_inner()) = Some(store); +} + +fn publish(store: &GrantStore) { + #[cfg(kernel)] + octosense_ai_host::kernel::system_tools::set_grants(store.tools()); + #[cfg(not(kernel))] + let _ = store; +} + +pub fn command_execution() -> bool { + with(|s| s.grants.command_execution) +} + +/// Settings' switch. On needs the person's gesture; off never does. +pub fn set_command_execution(on: bool, gesture: Option) -> Result<(), String> { + with(|s| { + s.set_command_execution(on, gesture)?; + publish(s); + Ok(()) + }) +} + +/// Where the switch stands against the running kernel. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Applied { + /// The running kernel has it. + Yes, + /// The running kernel started with the other value: restart to apply. + NeedsRestart, + /// No kernel runs: the next start takes it. + NextStart, +} + +pub fn applied() -> Applied { + #[cfg(kernel)] + { + match octosense_ai_host::kernel::system_agent_tools_in_effect() { + None => Applied::NextStart, + Some(t) if t.command_execution() == command_execution() => Applied::Yes, + Some(_) => Applied::NeedsRestart, + } + } + #[cfg(not(kernel))] + Applied::NextStart +} + +/// Whether the system agent may run commands right now: the kernel that +/// runs its turns started with the grant. +pub fn command_execution_in_effect() -> bool { + #[cfg(kernel)] + return octosense_ai_host::kernel::system_agent_tools_in_effect().is_some_and(|t| t.command_execution()); + #[cfg(not(kernel))] + false +} + +/// Restart the assistant so a changed switch applies. True when a kernel +/// was running (its consumers reconnect; the chat resumes its session). +pub fn restart_assistant() -> bool { + #[cfg(kernel)] + return octosense_ai_host::kernel::restart(); + #[cfg(not(kernel))] + false +} + +/// One `terminal.run` call of the system agent's (`call_id`: the kernel's +/// id for it). Refused unless the kernel runs with the grant; otherwise +/// the approval router takes it as a command: developer mode may answer +/// it, no standing rule can, and the person sees the exact command. +pub fn request_command(granted: bool, call_id: &str, command: &str, cwd: Option<&str>) -> Route { + if !granted { + return Route::Refused("The assistant may not run commands. Setup \u{203a} Assistant \u{203a} Command execution turns it on.".into()); + } + let mut args = serde_json::json!({ "command": command }); + if let Some(cwd) = cwd { + args["cwd"] = serde_json::json!(cwd); + } + let context = RequestContext { call_id: format!("{}{call_id}", super::HELD_PREFIX), trigger: Trigger::Person, ..RequestContext::default() }; + approvals::approval_requested(COMMAND_APP, ToolSpec::host(COMMAND_TOOL).command(), args, Caller::SystemAgent, context) +} diff --git a/crates/shell/src/system_chat/link.rs b/crates/shell/src/system_chat/link.rs new file mode 100644 index 00000000..4a263ee7 --- /dev/null +++ b/crates/shell/src/system_chat/link.rs @@ -0,0 +1,99 @@ +//! The system chat's link to the shell's ONE kernel: a consumer +//! connection from `octosense_kernel::connect()` (through +//! `octosense_ai_host::kernel`), the same host connection every native +//! consumer shares: stdio frames, or the host-token WebSocket while Talk to +//! Octos is on. No second kernel, no external token. +//! +//! The kernel's `Connection::recv` is async; the chat's thread polls it +//! with a waker that unparks the thread, so the shell needs no runtime of +//! its own. + +use super::session::{Closed, Connector, Link, Recv, Unavailable}; +use std::future::Future; +use std::pin::pin; +use std::sync::Arc; +use std::task::{Context, Poll, Wake, Waker}; +use std::time::{Duration, Instant}; + +use octosense_ai_host::kernel as core; + +struct Unpark(std::thread::Thread); + +impl Wake for Unpark { + fn wake(self: Arc) { + self.0.unpark(); + } + fn wake_by_ref(self: &Arc) { + self.0.unpark(); + } +} + +/// Poll `fut` on this thread until it is ready or `wait` passes. Dropping +/// the future on timeout is safe for `Connection::recv` (a channel receive). +fn poll_for(fut: F, wait: Duration) -> Option { + let waker = Waker::from(Arc::new(Unpark(std::thread::current()))); + let mut cx = Context::from_waker(&waker); + let mut fut = pin!(fut); + let deadline = Instant::now() + wait; + loop { + if let Poll::Ready(out) = fut.as_mut().poll(&mut cx) { + return Some(out); + } + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + return None; + } + std::thread::park_timeout(left); + } +} + +struct KernelLink(core::Connection); + +impl Link for KernelLink { + fn send(&mut self, frame: String) -> Result<(), Closed> { + self.0.send(frame).map_err(closed) + } + fn recv(&mut self, wait: Duration) -> Recv { + match poll_for(self.0.recv(), wait) { + None => Recv::Idle, + Some(Ok(frame)) => Recv::Frame(frame), + Some(Err(reason)) => Recv::Closed(closed(reason)), + } + } +} + +fn closed(reason: core::CloseReason) -> Closed { + Closed { restarted: reason == core::CloseReason::Restarted, why: reason.to_string() } +} + +/// A link over one kernel connection (tests use a kernel of their own). +pub(crate) fn link(conn: core::Connection) -> Box { + Box::new(KernelLink(conn)) +} + +/// The shell's kernel. +pub struct KernelConnector; + +impl Connector for KernelConnector { + fn connect(&mut self) -> Result, Unavailable> { + if let Err(why) = core::launch() { + return Err(Unavailable::NoKernel(why.to_string())); + } + if !provider_configured(core::profile().as_deref()) { + return Err(Unavailable::NoProvider); + } + match core::connect() { + Ok(conn) => Ok(Box::new(KernelLink(conn))), + Err(why) => Err(Unavailable::Failed(why.to_string())), + } + } +} + +/// Whether the kernel's profile names a model provider (the AI providers +/// app writes `config.llm.primary`). Nothing is started without one. +pub fn provider_configured(profile: Option<&std::path::Path>) -> bool { + let Some(path) = profile else { return false }; + let Ok(bytes) = std::fs::read(path) else { return false }; + let Ok(v) = serde_json::from_slice::(&bytes) else { return false }; + v["config"]["llm"]["primary"].is_object() +} diff --git a/crates/shell/src/system_chat/mod.rs b/crates/shell/src/system_chat/mod.rs new file mode 100644 index 00000000..d8a7b7f7 --- /dev/null +++ b/crates/shell/src/system_chat/mod.rs @@ -0,0 +1,338 @@ +//! The system chat: the person's conversation with the system agent +//! (`_main:api:octosense#system`), inside the shell (ADR 0004 §6, §8, +//! §12; docs/architecture.md, "Agents"). +//! +//! | Part | Module | +//! | --- | --- | +//! | the conversation as frames make it: streamed text, tool calls, questions, approvals, history | [`model`] | +//! | the session driver: open, hydrate, turns, interrupt, new conversation, reconnect and resume | [`session`] | +//! | its link: the shell's one kernel through `octosense_ai_host::kernel` | `link` (with a kernel) | +//! | Setup → Assistant → Command execution: the grant, the person's gesture, restart to apply | [`grants`] | +//! | the pane (desktop side panel, phone full screen) | [`view`] | +//! +//! **Where it runs.** A thread owns the [`session::Driver`] and its link; +//! the UI thread sends it [`session::Command`]s and draws a snapshot of the +//! model. The chat connects when the pane opens and lets the connection go +//! when it closes with nothing running, so the kernel's idle stop still +//! works. +//! +//! **Approvals.** Every `approval/requested` of the conversation goes to the +//! shell's approval router ([`crate::approvals`]) as the system agent's +//! call, batched per request (the turn; its prompt is the plan), with the +//! id `syschat:`; the router's decisions come back through +//! [`crate::approvals::take_system_chat_decisions`] and only then does the +//! chat answer the kernel. The pane never approves anything. + +pub mod grants; +pub mod model; +pub mod session; +pub mod view; + +#[cfg(kernel)] +pub(crate) mod link; +#[cfg(kernel)] +pub use link::provider_configured; + +#[cfg(test)] +mod tests; + +use makepad_widgets::*; +use model::{ChatModel, Effect}; +use session::{Command, Connector, Driver}; +use std::sync::mpsc; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +/// The approval router's ids for this chat's approvals. +pub const HELD_PREFIX: &str = "syschat:"; +/// The owning app of the system agent's own octos tool approvals, as the +/// sheet names it. +pub const APP: &str = "assistant"; + +struct Shared { + model: ChatModel, + effects: Vec, +} + +struct Worker { + tx: mpsc::Sender, + thread: std::thread::Thread, +} + +struct Chat { + open: bool, + draft: String, + /// Bumped on UI-only changes (open, the draft). + ui_generation: u64, + shared: Arc>, + worker: Option, + /// Scroll back from the newest line, in pixels. + pub scroll: f64, +} + +static CHAT: Mutex> = Mutex::new(None); + +fn with(f: impl FnOnce(&mut Chat) -> R) -> R { + let mut guard = CHAT.lock().unwrap_or_else(|e| e.into_inner()); + let chat = guard.get_or_insert_with(|| Chat { + open: false, + draft: String::new(), + ui_generation: 0, + shared: Arc::new(Mutex::new(Shared { model: ChatModel::new(), effects: Vec::new() })), + worker: None, + scroll: 0.0, + }); + f(chat) +} + +fn connector() -> Box { + #[cfg(kernel)] + return Box::new(link::KernelConnector); + #[cfg(not(kernel))] + Box::new(NoKernel) +} + +#[cfg(not(kernel))] +struct NoKernel; + +#[cfg(not(kernel))] +impl Connector for NoKernel { + fn connect(&mut self) -> Result, session::Unavailable> { + Err(session::Unavailable::NoKernel("this build has no assistant (feature `octos-core`)".into())) + } +} + +/// The chat's thread: steps the driver, publishes the model when it +/// changed, and wakes the UI. +fn spawn(shared: Arc>) -> Worker { + let (tx, rx) = mpsc::channel::(); + let handle = std::thread::Builder::new() + .name("system-chat".into()) + .spawn(move || { + let mut driver = Driver::new(connector()); + let mut seen = u64::MAX; + loop { + loop { + match rx.try_recv() { + Ok(cmd) => driver.command(cmd), + Err(mpsc::TryRecvError::Empty) => break, + Err(mpsc::TryRecvError::Disconnected) => return, + } + } + driver.step(Duration::from_millis(if driver.is_connected() { 100 } else { 250 })); + if driver.model.generation != seen || !driver.effects.is_empty() { + seen = driver.model.generation; + let mut s = shared.lock().unwrap_or_else(|e| e.into_inner()); + s.model = driver.model.clone(); + s.effects.append(&mut driver.effects); + drop(s); + SignalToUI::set_ui_signal(); + } + } + }) + .expect("system chat thread"); + Worker { tx, thread: handle.thread().clone() } +} + +fn command(cmd: Command) { + with(|c| { + if c.worker.is_none() { + c.worker = Some(spawn(c.shared.clone())); + } + let w = c.worker.as_ref().unwrap(); + let _ = w.tx.send(cmd); + w.thread.unpark(); + }); +} + +// ------------------------------------------------------------ the pane + +/// At startup: the command-execution grant of this home. +pub fn init(home: &std::path::Path) { + grants::init(home); +} + +pub fn is_open() -> bool { + with(|c| c.open) +} + +pub fn open() { + with(|c| { + c.open = true; + c.ui_generation += 1; + }); + command(Command::Open); +} + +pub fn close() { + with(|c| { + c.open = false; + c.ui_generation += 1; + }); + command(Command::Close); +} + +pub fn toggle() { + if is_open() { + close() + } else { + open() + } +} + +/// Send the draft (or answer the open question with it). +pub fn send_draft() { + let text = with(|c| { + c.ui_generation += 1; + c.scroll = 0.0; + std::mem::take(&mut c.draft) + }); + send(&text); +} + +/// Send `text` as the person's next message; with a question open, it +/// answers the question instead. +pub fn send(text: &str) { + let text = text.trim(); + if text.is_empty() { + return; + } + let question = with(|c| c.shared.lock().unwrap_or_else(|e| e.into_inner()).model.open_question().map(|(id, n)| (id.to_string(), n))); + match question { + Some((question, count)) => command(Command::Answer { question, count, text: text.to_string(), option: false }), + None => command(Command::Send(text.to_string())), + } +} + +pub fn answer_option(question: &str, count: usize, label: &str) { + command(Command::Answer { question: question.to_string(), count, text: label.to_string(), option: true }); +} + +pub fn interrupt() { + command(Command::Interrupt); +} + +pub fn new_conversation() { + command(Command::NewConversation); +} + +/// The model as the pane draws it. +pub fn snapshot() -> ChatModel { + with(|c| c.shared.lock().unwrap_or_else(|e| e.into_inner()).model.clone()) +} + +pub fn draft() -> String { + with(|c| c.draft.clone()) +} + +pub fn scroll() -> f64 { + with(|c| c.scroll) +} + +pub fn scroll_by(dy: f64, max: f64) { + with(|c| { + c.scroll = (c.scroll + dy).clamp(0.0, max.max(0.0)); + c.ui_generation += 1; + }); +} + +/// One number for "redraw". +pub fn generation() -> u64 { + with(|c| c.ui_generation + c.shared.lock().unwrap_or_else(|e| e.into_inner()).model.generation) +} + +/// On the UI thread, after a signal or the approvals tick: hand the new +/// approvals to the router, and the router's decisions to the kernel. +pub fn pump() { + let effects = with(|c| std::mem::take(&mut c.shared.lock().unwrap_or_else(|e| e.into_inner()).effects)); + for effect in effects { + match effect { + Effect::Approval(ask) => { + let route = route_approval(&ask); + if let crate::approvals::Route::Refused(why) = route { + log!("system chat: approval {} refused: {why}", ask.approval_id); + } + } + Effect::ApprovalGone(_) => {} + } + } + for (id, decision, _reason) in crate::approvals::take_system_chat_decisions() { + if let Some(approval_id) = id.0.strip_prefix(HELD_PREFIX) { + command(Command::Approval { approval_id: approval_id.to_string(), approve: decision.approved() }); + } + } +} + +/// One of the conversation's approvals, to the router: the system agent's +/// call, batched per request (its turn; the prompt is the plan). +pub fn route_approval(ask: &model::ApprovalAsk) -> crate::approvals::Route { + use crate::approvals::{Batch, Caller, RequestContext, ToolSpec, Trigger}; + let tool = if ask.tool == grants::COMMAND_TOOL { ToolSpec::host(&ask.tool).command() } else { ToolSpec::host(&ask.tool) }; + let app = if ask.tool == grants::COMMAND_TOOL { grants::COMMAND_APP } else { APP }; + let context = RequestContext { + call_id: format!("{HELD_PREFIX}{}", ask.approval_id), + trigger: Trigger::Person, + batch: Some(Batch { id: format!("{HELD_PREFIX}{}", ask.turn), plan: ask.plan.clone() }), + ..RequestContext::default() + }; + crate::approvals::approval_requested(app, tool, ask.args.clone(), Caller::SystemAgent, context) +} + +/// The keyboard while the pane is open. True when it was the pane's. +pub fn key(e: &KeyEvent) -> bool { + if !is_open() { + return false; + } + let command_key = e.modifiers.logo || e.modifiers.control; + match e.key_code { + KeyCode::Escape => close(), + KeyCode::ReturnKey if !e.modifiers.shift => send_draft(), + KeyCode::Backspace => with(|c| { + c.draft.pop(); + c.ui_generation += 1; + }), + KeyCode::KeyN if command_key => new_conversation(), + KeyCode::Period if command_key => interrupt(), + other if !command_key => { + let Some(ch) = other.to_char(e.modifiers.shift) else { return true }; + with(|c| { + c.draft.push(ch); + c.ui_generation += 1; + }); + } + _ => return false, + } + true +} + +/// Text from the platform's input method (phones), while the pane is open. +pub fn text_input(text: &str) -> bool { + if !is_open() { + return false; + } + with(|c| { + c.draft.push_str(text); + c.ui_generation += 1; + }); + true +} + +/// `--test-action system-chat` (open the pane) and +/// `--test-action system-chat-send:` (open it and send a prompt), +/// for hidden-window runs. +pub fn test_action(name: &str) -> bool { + if name == "system-chat" { + open(); + return true; + } + if let Some(text) = name.strip_prefix("system-chat-send:") { + open(); + send(text); + return true; + } + false +} + +pub fn script_mod(vm: &mut ScriptVm) { + view::script_mod(vm); +} diff --git a/crates/shell/src/system_chat/model.rs b/crates/shell/src/system_chat/model.rs new file mode 100644 index 00000000..28f738e6 --- /dev/null +++ b/crates/shell/src/system_chat/model.rs @@ -0,0 +1,469 @@ +//! The system chat's model: the conversation as the pane draws it, built +//! only from the kernel's UI Protocol frames (OUP `octos-ui/v1alpha1`) and +//! the person's own actions. No drawing and no I/O here, so it is tested +//! frame by frame. +//! +//! - **Streamed text**: `message/delta` (and a v2 `projection/envelope`'s +//! `assistant_delta` / `assistant_persisted`) grow the turn's assistant +//! message. +//! - **Tool calls** with their status: `tool/started` → running, +//! `tool/progress` → its message, `tool/completed` → done or failed. +//! - **Questions**: `user_question/requested` (the system agent's +//! `ask_user_question`), answered from the pane. +//! - **Approvals** are NOT answered here: `approval/requested` becomes an +//! [`Effect::Approval`] for the shell's approval router (ADR 0004 §8); the +//! pane only notes that the sheet is waiting and what was decided. +//! - **History**: `session/hydrate`'s rows replace the transcript. + +use serde_json::Value; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Role { + User, + Assistant, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ToolStatus { + Running, + Done, + Failed, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ApprovalState { + /// On the shell's sheet, waiting for the person. + Waiting, + Approved, + Denied, + /// The kernel withdrew it (the turn ended). + Cancelled, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Item { + Message { + role: Role, + text: String, + turn: Option, + }, + Tool { + call_id: String, + name: String, + status: ToolStatus, + detail: String, + turn: Option, + }, + Question { + id: String, + turn: String, + title: String, + body: String, + /// The first question's options (labels). + options: Vec, + /// How many questions the kernel asked (each is answered). + count: usize, + answered: Option, + }, + /// A tool approval the shell's router holds; the sheet shows the call. + Approval { + id: String, + tool: String, + title: String, + state: ApprovalState, + }, + /// Something the person should know (an error, a restart). + Notice(String), +} + +/// Where the conversation stands. +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub enum Phase { + /// Not connected (the pane is closed, or has not connected yet). + #[default] + Idle, + Connecting, + /// No kernel on this shell (no binary, iOS, a build without one). + NoKernel(String), + /// No model provider is configured: nothing to talk to. + NoProvider, + /// Open and waiting for the person. + Ready, + /// A turn is running (the pane's own, or another client's on the same + /// system conversation). + Running { turn: String }, + /// The kernel went away; the chat reconnects and resumes the session. + Reconnecting(String), +} + +impl Phase { + pub fn running_turn(&self) -> Option<&str> { + match self { + Phase::Running { turn } => Some(turn), + _ => None, + } + } +} + +/// An approval the chat hands to the approval router. +#[derive(Clone, Debug, PartialEq)] +pub struct ApprovalAsk { + pub approval_id: String, + pub turn: String, + pub tool: String, + pub title: String, + pub body: String, + /// The exact arguments as the kernel gave them (typed details when + /// present, else the title and body). + pub args: Value, + /// The person's words that started the turn: the batch's plan. + pub plan: String, +} + +/// What the driver must do after a frame. +#[derive(Clone, Debug, PartialEq)] +pub enum Effect { + Approval(ApprovalAsk), + /// The approval's outcome is known; the router's pending request, if + /// any, is moot. + ApprovalGone(String), +} + +#[derive(Clone, Debug, Default)] +pub struct ChatModel { + pub items: Vec, + pub phase: Phase, + /// Bumped on every visible change. + pub generation: u64, + /// The prompt of each turn this model saw start (for approval batches). + prompts: Vec<(String, String)>, + /// v2 envelope segments whose saved text arrived. + persisted: Vec, + segment: String, +} + +impl ChatModel { + pub fn new() -> Self { + Self::default() + } + + pub fn phase(&self) -> &Phase { + &self.phase + } + + pub fn set_phase(&mut self, phase: Phase) { + if self.phase != phase { + self.phase = phase; + self.changed(); + } + } + + fn changed(&mut self) { + self.generation += 1; + } + + pub fn notice(&mut self, text: impl Into) { + self.items.push(Item::Notice(text.into())); + self.changed(); + } + + /// The person sent `text` as turn `turn`. + pub fn start_turn(&mut self, turn: &str, text: &str) { + self.items.push(Item::Message { role: Role::User, text: text.to_string(), turn: Some(turn.to_string()) }); + self.prompts.push((turn.to_string(), text.to_string())); + self.phase = Phase::Running { turn: turn.to_string() }; + self.changed(); + } + + /// The prompt that started `turn`, if this model saw it. + pub fn prompt_of(&self, turn: &str) -> Option<&str> { + self.prompts.iter().rev().find(|(t, _)| t == turn).map(|(_, p)| p.as_str()) + } + + /// The turn ended (completed, failed, interrupted or lost): back to + /// ready, and any tool still running is marked failed. + pub fn end_turn(&mut self, turn: &str, error: Option<&str>) { + for item in &mut self.items { + if let Item::Tool { status, turn: Some(t), .. } = item { + if t == turn && *status == ToolStatus::Running { + *status = ToolStatus::Failed; + } + } + if let Item::Approval { state, .. } = item { + if *state == ApprovalState::Waiting { + *state = ApprovalState::Cancelled; + } + } + } + if let Some(error) = error { + self.items.push(Item::Notice(error.to_string())); + } + if self.phase.running_turn() == Some(turn) || matches!(self.phase, Phase::Running { .. }) { + self.phase = Phase::Ready; + } + self.changed(); + } + + /// The assistant's text for `turn` grows by `delta`. + fn append_assistant(&mut self, turn: &str, delta: &str) { + if let Some(Item::Message { role: Role::Assistant, text, turn: Some(t) }) = self.items.last_mut() { + if t == turn { + text.push_str(delta); + self.changed(); + return; + } + } + self.items.push(Item::Message { role: Role::Assistant, text: delta.to_string(), turn: Some(turn.to_string()) }); + self.changed(); + } + + /// The saved text of the turn's last assistant message replaces what + /// streamed. + fn set_assistant(&mut self, turn: &str, full: &str) { + if let Some(Item::Message { role: Role::Assistant, text, turn: Some(t) }) = self.items.last_mut() { + if t == turn { + *text = full.to_string(); + self.changed(); + return; + } + } + self.append_assistant(turn, full); + } + + fn tool_mut(&mut self, call_id: &str) -> Option<&mut Item> { + self.items.iter_mut().rev().find(|i| matches!(i, Item::Tool { call_id: c, .. } if c == call_id)) + } + + /// One notification for the system session. Returns what the driver + /// must do next. + pub fn apply(&mut self, method: &str, params: &Value) -> Vec { + let s = |k: &str| params.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + let turn = s("turn_id"); + let mut effects = Vec::new(); + match method { + "turn/started" => { + // Another client's turn on the same conversation (Talk to + // Octos), or the kernel confirming ours. + self.phase = Phase::Running { turn }; + self.changed(); + } + "message/delta" => self.append_assistant(&turn, &s("text")), + "tool/started" => { + let detail = params.get("arguments").map(brief_args).unwrap_or_default(); + self.items.push(Item::Tool { call_id: s("tool_call_id"), name: s("tool_name"), status: ToolStatus::Running, detail, turn: Some(turn) }); + self.changed(); + } + "tool/progress" => { + let message = s("message"); + if let Some(Item::Tool { detail, .. }) = self.tool_mut(&s("tool_call_id")) { + if !message.is_empty() { + *detail = message; + } + } + self.changed(); + } + "tool/completed" => { + let ok = params.get("success").and_then(Value::as_bool).unwrap_or(true); + let preview = s("output_preview"); + let call_id = s("tool_call_id"); + let name = s("tool_name"); + match self.tool_mut(&call_id) { + Some(Item::Tool { status, detail, .. }) => { + *status = if ok { ToolStatus::Done } else { ToolStatus::Failed }; + if !ok && !preview.is_empty() { + *detail = preview; + } + } + _ => self.items.push(Item::Tool { + call_id, + name, + status: if ok { ToolStatus::Done } else { ToolStatus::Failed }, + detail: String::new(), + turn: Some(turn), + }), + } + self.changed(); + } + "user_question/requested" => { + let questions = params.get("questions").and_then(Value::as_array).cloned().unwrap_or_default(); + let first = questions.first(); + let options = first + .and_then(|q| q.get("options")) + .and_then(Value::as_array) + .map(|o| o.iter().filter_map(|o| o.get("label").and_then(Value::as_str)).map(str::to_string).collect()) + .unwrap_or_default(); + let body = first.and_then(|q| q.get("question")).and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| s("body")); + self.items.push(Item::Question { id: s("question_id"), turn, title: s("title"), body, options, count: questions.len().max(1), answered: None }); + self.changed(); + } + "approval/requested" => { + let id = s("approval_id"); + let tool = s("tool_name"); + let title = s("title"); + let body = s("body"); + let args = match params.get("typed_details") { + Some(details) if !details.is_null() => details.clone(), + _ => serde_json::json!({ "title": title, "body": body }), + }; + let plan = self.prompt_of(&turn).unwrap_or("The system agent's request").to_string(); + self.items.push(Item::Approval { id: id.clone(), tool: tool.clone(), title: title.clone(), state: ApprovalState::Waiting }); + self.changed(); + effects.push(Effect::Approval(ApprovalAsk { approval_id: id, turn, tool, title, body, args, plan })); + } + "approval/decided" => { + let approved = params.get("decision").and_then(Value::as_str) == Some("approve"); + let id = s("approval_id"); + self.set_approval(&id, if approved { ApprovalState::Approved } else { ApprovalState::Denied }); + effects.push(Effect::ApprovalGone(id)); + } + "approval/cancelled" => { + let id = s("approval_id"); + self.set_approval(&id, ApprovalState::Cancelled); + effects.push(Effect::ApprovalGone(id)); + } + "turn/completed" => self.end_turn(&turn, None), + "turn/error" => { + let message = params.get("message").and_then(Value::as_str).filter(|m| !m.is_empty()).map(str::to_string).unwrap_or_else(|| s("code")); + let message = if message.is_empty() { "The assistant's turn failed.".to_string() } else { message }; + self.end_turn(&turn, Some(&message)); + } + "projection/envelope" => self.envelope(&turn, ¶ms["payload"]), + _ => {} + } + effects + } + + /// A v2 projection envelope (a connection that negotiated + /// `projection.envelope.v2`; the stdio default does not). + fn envelope(&mut self, turn: &str, payload: &Value) { + let data = &payload["data"]; + match payload["type"].as_str().unwrap_or("") { + kind @ ("assistant_delta" | "assistant_persisted") => { + let segment = data["assistant_segment_id"].as_str().unwrap_or("").to_string(); + if kind == "assistant_delta" && self.persisted.contains(&segment) { + return; + } + let text = data["text"].as_str().unwrap_or(""); + if self.segment != segment { + self.segment = segment.clone(); + self.items.push(Item::Message { role: Role::Assistant, text: String::new(), turn: Some(turn.to_string()) }); + } + if kind == "assistant_persisted" { + self.persisted.push(segment); + self.set_assistant(turn, text); + } else { + self.append_assistant(turn, text); + } + } + "turn_terminal" => { + if data["outcome"] == "completed" { + self.end_turn(turn, None); + } else { + let message = data["error"]["message"].as_str().unwrap_or("The assistant's turn did not complete.").to_string(); + self.end_turn(turn, Some(&message)); + } + } + _ => {} + } + } + + fn set_approval(&mut self, id: &str, to: ApprovalState) { + for item in &mut self.items { + if let Item::Approval { id: i, state, .. } = item { + if i == id && *state == ApprovalState::Waiting { + *state = to.clone(); + } + } + } + self.changed(); + } + + /// The router decided (the person, a rule or developer mode). + pub fn approval_decided(&mut self, id: &str, approved: bool) { + self.set_approval(id, if approved { ApprovalState::Approved } else { ApprovalState::Denied }); + } + + /// The person answered a question from the pane. + pub fn question_answered(&mut self, id: &str, answer: &str) { + for item in &mut self.items { + if let Item::Question { id: i, answered, .. } = item { + if i == id { + *answered = Some(answer.to_string()); + } + } + } + self.changed(); + } + + /// The question waiting for the person, if any. + pub fn open_question(&self) -> Option<(&str, usize)> { + self.items.iter().rev().find_map(|i| match i { + Item::Question { id, answered: None, count, .. } => Some((id.as_str(), *count)), + _ => None, + }) + } + + /// The transcript from `session/hydrate` (`result.messages`), replacing + /// what the pane showed. Rows are `{role, content, turn_id?}`. + pub fn load_history(&mut self, messages: &Value) { + let mut items = Vec::new(); + for row in messages.as_array().into_iter().flatten() { + let text = row.get("content").and_then(Value::as_str).unwrap_or("").to_string(); + let turn = row.get("turn_id").and_then(Value::as_str).map(str::to_string); + match row.get("role").and_then(Value::as_str).unwrap_or("") { + "user" => items.push(Item::Message { role: Role::User, text, turn }), + "assistant" if !text.trim().is_empty() => items.push(Item::Message { role: Role::Assistant, text, turn }), + "tool" => items.push(Item::Tool { + call_id: row.get("tool_call_id").and_then(Value::as_str).unwrap_or("").to_string(), + name: row.get("name").or_else(|| row.get("tool_name")).and_then(Value::as_str).unwrap_or("tool").to_string(), + status: ToolStatus::Done, + detail: String::new(), + turn, + }), + _ => {} + } + } + // Keep what history cannot know: notices and a question or approval + // still open. + for item in std::mem::take(&mut self.items) { + let keep = match &item { + Item::Question { answered: None, .. } => true, + Item::Approval { state: ApprovalState::Waiting, .. } => true, + _ => false, + }; + if keep { + items.push(item); + } + } + self.items = items; + self.changed(); + } + + /// New conversation: the transcript is cleared at once. + pub fn clear(&mut self) { + self.items.clear(); + self.prompts.clear(); + self.changed(); + } +} + +/// One short line for a tool call's arguments ("path: notes.md"). +pub fn brief_args(args: &Value) -> String { + match args { + Value::Object(map) => map + .iter() + .take(3) + .map(|(k, v)| { + let v = match v { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + let v: String = v.chars().take(60).collect(); + format!("{k}: {v}") + }) + .collect::>() + .join(" \u{00b7} "), + Value::Null => String::new(), + other => other.to_string().chars().take(80).collect(), + } +} diff --git a/crates/shell/src/system_chat/session.rs b/crates/shell/src/system_chat/session.rs new file mode 100644 index 00000000..75a29036 --- /dev/null +++ b/crates/shell/src/system_chat/session.rs @@ -0,0 +1,426 @@ +//! The system chat's session driver: one kernel connection, the system +//! agent's session, requests and their replies, reconnect and resume. +//! +//! It speaks OUP over a [`Link`], which is the shell's EXISTING host +//! connection to its one kernel (`octosense_kernel::connect()`: stdio, or +//! the host-token WebSocket while Talk to Octos is on; see `link.rs`). It +//! never starts a second kernel and never holds an external token. Tests +//! drive it with a scripted link. +//! +//! - **Open**: `session/open {session_id: SYSTEM_SESSION, profile_id: +//! "_main"}`, then `session/hydrate {include: ["messages"]}` for the +//! history (the `session/messages_page` fallback when hydrate is +//! refused). +//! - **Send**: `turn/start` with a fresh turn id; **Stop**: +//! `turn/interrupt`; **New conversation**: the kernel's `/new` (it clears +//! the conversation without calling the model), then the history again. +//! - **Questions**: `user_question/respond`. **Approvals**: the driver +//! never decides one: it answers `approval/respond` only with what the +//! shell's approval router decided ([`Command::Approval`]). +//! - **The kernel restarts** (a provider change, Settings' restart, a +//! crash): the link ends, the driver reconnects with a back-off and +//! reopens the same session, so the conversation resumes; a turn that +//! was running is reported as stopped. + +use super::model::{ChatModel, Effect, Phase}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +/// The system agent's conversation (octosense-kernel `SYSTEM_SESSION`). +pub const SYSTEM_SESSION: &str = "_main:api:octosense#system"; +pub const SYSTEM_PROFILE: &str = "_main"; + +/// How a link ended. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Closed { + /// The kernel was restarted on purpose (reconnect at once). + pub restarted: bool, + pub why: String, +} + +/// One frame, nothing yet, or the end. +#[derive(Debug)] +pub enum Recv { + Frame(String), + Idle, + Closed(Closed), +} + +/// A connection to the kernel. +pub trait Link: Send { + fn send(&mut self, frame: String) -> Result<(), Closed>; + /// The next frame, waiting at most `wait`. + fn recv(&mut self, wait: Duration) -> Recv; +} + +/// Why no link could be made. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Unavailable { + /// No kernel on this shell at all (no binary, iOS): nothing to retry + /// until something changes. + NoKernel(String), + /// No model provider is configured. + NoProvider, + /// It failed to start; retry. + Failed(String), +} + +/// Makes links to the shell's kernel. +pub trait Connector: Send { + fn connect(&mut self) -> Result, Unavailable>; +} + +/// What the UI asks of the driver. +#[derive(Clone, Debug, PartialEq)] +pub enum Command { + /// Connect (the pane opened). + Open, + /// Let the connection go when nothing runs (the pane closed). + Close, + Send(String), + Interrupt, + NewConversation, + /// Answer the open question with one option or free text. + Answer { question: String, count: usize, text: String, option: bool }, + /// The approval router's decision for one approval (never the pane's). + Approval { approval_id: String, approve: bool }, +} + +#[derive(Clone, Debug)] +enum Pending { + Open, + History { fallback: bool }, + Turn { turn: String }, + NewConversation, + Other(&'static str), +} + +/// The driver. [`Driver::step`] does one round: it is what the chat's +/// thread loops on, and what tests call. +pub struct Driver { + connector: Box, + link: Option>, + pub model: ChatModel, + /// Approvals to hand to the router (drained by the shell). + pub effects: Vec, + pending: HashMap, + next_id: u64, + /// The pane wants a connection. + wanted: bool, + retry_at: Option, + backoff: Duration, + /// Commands that wait for the session to open. + queued: Vec, + opened: bool, +} + +impl Driver { + pub fn new(connector: Box) -> Self { + Driver { + connector, + link: None, + model: ChatModel::new(), + effects: Vec::new(), + pending: HashMap::new(), + next_id: 0, + wanted: false, + retry_at: None, + backoff: Duration::from_millis(500), + queued: Vec::new(), + opened: false, + } + } + + pub fn is_connected(&self) -> bool { + self.link.is_some() + } + + fn request(&mut self, method: &str, params: Value, pending: Pending) -> bool { + self.next_id += 1; + let id = format!("syschat-{}", self.next_id); + let frame = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string(); + let Some(link) = self.link.as_mut() else { return false }; + match link.send(frame) { + Ok(()) => { + self.pending.insert(id, pending); + true + } + Err(closed) => { + self.lost(closed); + false + } + } + } + + /// One command from the UI. + pub fn command(&mut self, cmd: Command) { + match cmd { + Command::Open => { + self.wanted = true; + if self.link.is_none() { + self.retry_at = None; + self.try_connect(); + } + } + Command::Close => { + self.wanted = false; + if self.model.phase().running_turn().is_none() { + self.drop_link(); + self.model.set_phase(Phase::Idle); + } + } + other if !self.opened => { + // Not open yet: connect, and do it once the session is open. + self.wanted = true; + self.queued.push(other); + if self.link.is_none() { + self.try_connect(); + } + } + Command::Send(text) => { + let text = text.trim().to_string(); + if text.is_empty() { + return; + } + if let Some(turn) = self.model.phase().running_turn() { + let _ = turn; + self.model.notice("Wait for the current answer, or stop it first."); + return; + } + let turn = new_turn_id(); + self.model.start_turn(&turn, &text); + let params = json!({"session_id": SYSTEM_SESSION, "turn_id": turn, "input": [{"kind": "text", "text": text}]}); + self.request("turn/start", params, Pending::Turn { turn }); + } + Command::Interrupt => { + if let Some(turn) = self.model.phase().running_turn().map(str::to_string) { + self.request("turn/interrupt", json!({"session_id": SYSTEM_SESSION, "turn_id": turn}), Pending::Other("turn/interrupt")); + self.model.end_turn(&turn, Some("Stopped.")); + } + } + Command::NewConversation => { + if self.model.phase().running_turn().is_some() { + self.model.notice("Stop the current answer before starting a new conversation."); + return; + } + self.model.clear(); + let turn = new_turn_id(); + let params = json!({"session_id": SYSTEM_SESSION, "turn_id": turn, "input": [{"kind": "text", "text": "/new"}]}); + self.request("turn/start", params, Pending::NewConversation); + } + Command::Answer { question, count, text, option } => { + let one = if option { json!({"selected_labels": [text]}) } else { json!({"free_text": text}) }; + let answers: Vec = (0..count.max(1)).map(|_| one.clone()).collect(); + self.model.question_answered(&question, &text); + self.request("user_question/respond", json!({"session_id": SYSTEM_SESSION, "question_id": question, "answers": answers}), Pending::Other("user_question/respond")); + } + Command::Approval { approval_id, approve } => { + self.model.approval_decided(&approval_id, approve); + let decision = if approve { "approve" } else { "deny" }; + self.request( + "approval/respond", + json!({"session_id": SYSTEM_SESSION, "approval_id": approval_id, "decision": decision, "client_note": "decided by the OctoSense shell's approval router"}), + Pending::Other("approval/respond"), + ); + } + } + } + + fn drop_link(&mut self) { + self.link = None; + self.pending.clear(); + self.opened = false; + } + + fn try_connect(&mut self) { + if !self.wanted { + return; + } + self.model.set_phase(Phase::Connecting); + match self.connector.connect() { + Ok(link) => { + self.link = Some(link); + self.backoff = Duration::from_millis(500); + self.retry_at = None; + self.request("session/open", json!({"session_id": SYSTEM_SESSION, "profile_id": SYSTEM_PROFILE}), Pending::Open); + } + Err(Unavailable::NoKernel(why)) => { + self.model.set_phase(Phase::NoKernel(why)); + // Something may change (a binary installed): look again later. + self.retry_at = Some(Instant::now() + Duration::from_secs(5)); + } + Err(Unavailable::NoProvider) => { + self.model.set_phase(Phase::NoProvider); + self.retry_at = Some(Instant::now() + Duration::from_secs(3)); + } + Err(Unavailable::Failed(why)) => self.schedule_retry(why), + } + } + + fn schedule_retry(&mut self, why: String) { + self.model.set_phase(Phase::Reconnecting(why)); + self.retry_at = Some(Instant::now() + self.backoff); + self.backoff = (self.backoff * 2).min(Duration::from_secs(10)); + } + + /// The link ended: report a running turn as stopped, then reconnect + /// (at once after a deliberate restart) and resume the session. + fn lost(&mut self, closed: Closed) { + self.drop_link(); + if let Some(turn) = self.model.phase().running_turn().map(str::to_string) { + self.model.end_turn(&turn, Some("The assistant restarted; that answer stopped. Ask again to continue.")); + } + let why = if closed.restarted { "The assistant is restarting".to_string() } else { format!("The assistant stopped: {}", closed.why) }; + if closed.restarted { + self.backoff = Duration::from_millis(200); + } + if self.wanted { + self.schedule_retry(why); + } else { + self.model.set_phase(Phase::Idle); + } + } + + /// One round: retry a connection that is due, then take frames for up + /// to `wait`. + pub fn step(&mut self, wait: Duration) { + if self.link.is_none() { + if self.wanted && self.retry_at.is_some_and(|t| Instant::now() >= t) { + self.retry_at = None; + self.try_connect(); + } + if self.link.is_none() { + if !wait.is_zero() { + std::thread::sleep(wait.min(Duration::from_millis(100))); + } + return; + } + } + let deadline = Instant::now() + wait; + loop { + let left = deadline.saturating_duration_since(Instant::now()); + let Some(link) = self.link.as_mut() else { return }; + match link.recv(left) { + Recv::Frame(frame) => self.frame(&frame), + Recv::Idle => return, + Recv::Closed(closed) => { + self.lost(closed); + return; + } + } + if Instant::now() >= deadline { + return; + } + } + } + + fn frame(&mut self, text: &str) { + let Ok(frame) = serde_json::from_str::(text) else { return }; + if let Some(id) = frame.get("id").filter(|_| frame.get("result").is_some() || frame.get("error").is_some()) { + let key = id.as_str().map(str::to_string).unwrap_or_else(|| id.to_string()); + if let Some(pending) = self.pending.remove(&key) { + self.reply(pending, frame.get("result"), frame.get("error").filter(|e| !e.is_null())); + } + return; + } + let Some(method) = frame.get("method").and_then(Value::as_str) else { return }; + let params = frame.get("params").cloned().unwrap_or(Value::Null); + // Only the system conversation (the router sends only the sessions + // this connection opened; be strict anyway). + let session = match (params.get("session_id").and_then(Value::as_str), params.get("topic").and_then(Value::as_str)) { + (Some(id), Some(topic)) if !id.contains('#') => format!("{id}#{topic}"), + (Some(id), _) => id.to_string(), + _ => String::new(), + }; + if session != SYSTEM_SESSION { + return; + } + let effects = self.model.apply(method, ¶ms); + self.effects.extend(effects); + } + + fn reply(&mut self, pending: Pending, result: Option<&Value>, error: Option<&Value>) { + let error_text = error.map(|e| e.get("message").and_then(Value::as_str).unwrap_or("error").to_string()); + match pending { + Pending::Open => match error_text { + Some(why) => { + let lower = why.to_lowercase(); + if lower.contains("provider") || lower.contains("profile") || lower.contains("llm") || lower.contains("model") { + self.model.set_phase(Phase::NoProvider); + } else { + self.model.notice(format!("Could not open the conversation: {why}")); + self.model.set_phase(Phase::Ready); + } + self.drop_link(); + self.retry_at = Some(Instant::now() + Duration::from_secs(3)); + } + None => { + self.opened = true; + self.model.set_phase(Phase::Ready); + self.load_history(); + for cmd in std::mem::take(&mut self.queued) { + self.command(cmd); + } + } + }, + Pending::History { fallback } => match (error_text, result) { + (None, Some(result)) => { + let rows = if fallback { &result["messages"] } else { &result["messages"] }; + self.model.load_history(rows); + } + (Some(_), _) if !fallback => { + self.request("session/messages_page", json!({"session_id": SYSTEM_SESSION, "limit": 200}), Pending::History { fallback: true }); + } + (Some(why), _) => self.model.notice(format!("Could not load the conversation: {why}")), + _ => {} + }, + Pending::Turn { turn } => { + if let Some(why) = error_text { + self.model.end_turn(&turn, Some(&format!("The assistant could not start: {why}"))); + } + } + Pending::NewConversation => { + if let Some(why) = error_text { + self.model.notice(format!("Could not start a new conversation: {why}")); + } + self.load_history(); + } + Pending::Other(what) => { + if let Some(why) = error_text { + self.model.notice(format!("{what}: {why}")); + } + } + } + } + + fn load_history(&mut self) { + self.request("session/hydrate", json!({"session_id": SYSTEM_SESSION, "include": ["messages"]}), Pending::History { fallback: false }); + } +} + +/// A random version-4 UUID (octos turn ids are UUIDs), from the standard +/// library's randomly seeded hasher, the clock and a counter. +pub fn new_turn_id() -> String { + use std::collections::hash_map::RandomState; + use std::hash::{BuildHasher, Hasher}; + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0); + let mut words = [0u64; 2]; + for (i, word) in words.iter_mut().enumerate() { + let mut h = RandomState::new().build_hasher(); + h.write_u128(nanos); + h.write_u64(n); + h.write_usize(i); + *word = h.finish(); + } + let mut b = [0u8; 16]; + b[..8].copy_from_slice(&words[0].to_le_bytes()); + b[8..].copy_from_slice(&words[1].to_le_bytes()); + b[6] = (b[6] & 0x0f) | 0x40; + b[8] = (b[8] & 0x3f) | 0x80; + let hex: String = b.iter().map(|x| format!("{x:02x}")).collect(); + format!("{}-{}-{}-{}-{}", &hex[0..8], &hex[8..12], &hex[12..16], &hex[16..20], &hex[20..32]) +} diff --git a/crates/shell/src/system_chat/tests.rs b/crates/shell/src/system_chat/tests.rs new file mode 100644 index 00000000..acdc4549 --- /dev/null +++ b/crates/shell/src/system_chat/tests.rs @@ -0,0 +1,514 @@ +//! The system chat against a scripted kernel connection: streaming +//! assembly, tool-call status, questions, interrupt, history, new +//! conversation, reconnect-and-resume after a kernel restart, approvals +//! through the router, and the command-execution switch. + +use super::grants::{self, CommandGesture, GrantStore}; +use super::model::{ApprovalState, ChatModel, Effect, Item, Phase, Role, ToolStatus}; +use super::session::{Closed, Command, Connector, Driver, Link, Recv, Unavailable, SYSTEM_SESSION}; +use serde_json::{json, Value}; +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +/// What the fake kernel saw and will say. +#[derive(Default)] +struct Script { + /// Every request the driver sent (method, params). + sent: Vec<(String, Value)>, + /// Frames waiting for the driver. + inbox: VecDeque, + /// History `session/hydrate` answers with. + history: Value, + /// Close the link at the next receive (a kernel restart). + close: Option, + connects: usize, + /// `connect` answers this instead of a link. + unavailable: Option, + /// `session/open` fails with this message. + open_error: Option, +} + +#[derive(Clone, Default)] +struct Fake(Arc>); + +impl Fake { + fn s(&self) -> std::sync::MutexGuard<'_, Script> { + self.0.lock().unwrap() + } + fn notify(&self, method: &str, mut params: Value) { + params["session_id"] = json!(SYSTEM_SESSION); + self.s().inbox.push_back(json!({"jsonrpc": "2.0", "method": method, "params": params}).to_string()); + } + fn sent(&self, method: &str) -> Vec { + self.s().sent.iter().filter(|(m, _)| m == method).map(|(_, p)| p.clone()).collect() + } +} + +struct FakeLink(Fake); + +impl Link for FakeLink { + fn send(&mut self, frame: String) -> Result<(), Closed> { + let v: Value = serde_json::from_str(&frame).unwrap(); + let method = v["method"].as_str().unwrap().to_string(); + let mut s = self.0.s(); + s.sent.push((method.clone(), v["params"].clone())); + let id = v["id"].clone(); + let reply = match method.as_str() { + "session/open" => match &s.open_error { + Some(e) => json!({"jsonrpc": "2.0", "id": id, "error": {"code": -32000, "message": e}}), + None => json!({"jsonrpc": "2.0", "id": id, "result": {"opened": {"session_id": SYSTEM_SESSION}}}), + }, + "session/hydrate" => json!({"jsonrpc": "2.0", "id": id, "result": {"messages": s.history.clone()}}), + _ => json!({"jsonrpc": "2.0", "id": id, "result": {}}), + }; + s.inbox.push_back(reply.to_string()); + Ok(()) + } + fn recv(&mut self, _wait: Duration) -> Recv { + let mut s = self.0.s(); + if let Some(closed) = s.close.take() { + return Recv::Closed(closed); + } + match s.inbox.pop_front() { + Some(f) => Recv::Frame(f), + None => Recv::Idle, + } + } +} + +struct FakeConnector(Fake); + +impl Connector for FakeConnector { + fn connect(&mut self) -> Result, Unavailable> { + let mut s = self.0.s(); + s.connects += 1; + if let Some(u) = s.unavailable.clone() { + return Err(u); + } + Ok(Box::new(FakeLink(self.0.clone()))) + } +} + +fn driver() -> (Driver, Fake) { + let fake = Fake::default(); + (Driver::new(Box::new(FakeConnector(fake.clone()))), fake) +} + +/// Step until nothing is left to read. +fn settle(d: &mut Driver) { + for _ in 0..20 { + d.step(Duration::ZERO); + } +} + +fn opened() -> (Driver, Fake) { + let (mut d, fake) = driver(); + d.command(Command::Open); + settle(&mut d); + assert_eq!(d.model.phase(), &Phase::Ready); + (d, fake) +} + +fn text_of(model: &ChatModel, role: Role) -> Vec { + model.items.iter().filter_map(|i| match i { + Item::Message { role: r, text, .. } if *r == role => Some(text.clone()), + _ => None, + }).collect() +} + +// ---------------------------------------------------------------- the model + +#[test] +fn deltas_assemble_the_answer_of_their_turn() { + let mut m = ChatModel::new(); + m.start_turn("t1", "What's on today?"); + for piece in ["You have ", "two ", "meetings."] { + m.apply("message/delta", &json!({"turn_id": "t1", "text": piece})); + } + assert_eq!(text_of(&m, Role::Assistant), ["You have two meetings."]); + assert_eq!(m.phase().running_turn(), Some("t1")); + m.apply("turn/completed", &json!({"turn_id": "t1"})); + assert_eq!(m.phase(), &Phase::Ready); + // The next turn's text starts a new message. + m.start_turn("t2", "And tomorrow?"); + m.apply("message/delta", &json!({"turn_id": "t2", "text": "Nothing."})); + assert_eq!(text_of(&m, Role::Assistant), ["You have two meetings.", "Nothing."]); +} + +#[test] +fn v2_envelopes_stream_and_the_saved_text_wins() { + let mut m = ChatModel::new(); + m.start_turn("t1", "hi"); + let env = |kind: &str, text: &str| json!({"turn_id": "t1", "payload": {"type": kind, "data": {"assistant_segment_id": "s1", "text": text}}}); + m.apply("projection/envelope", &env("assistant_delta", "Hel")); + m.apply("projection/envelope", &env("assistant_delta", "lo")); + m.apply("projection/envelope", &env("assistant_persisted", "Hello!")); + m.apply("projection/envelope", &env("assistant_delta", "late")); + assert_eq!(text_of(&m, Role::Assistant), ["Hello!"]); + m.apply("projection/envelope", &json!({"turn_id": "t1", "payload": {"type": "turn_terminal", "data": {"outcome": "completed"}}})); + assert_eq!(m.phase(), &Phase::Ready); +} + +#[test] +fn tool_calls_carry_their_status() { + let mut m = ChatModel::new(); + m.start_turn("t1", "read my notes"); + m.apply("tool/started", &json!({"turn_id": "t1", "tool_call_id": "c1", "tool_name": "read_file", "arguments": {"path": "notes.md"}})); + m.apply("tool/started", &json!({"turn_id": "t1", "tool_call_id": "c2", "tool_name": "web_fetch", "arguments": {"url": "https://example.org"}})); + let status = |m: &ChatModel, id: &str| m.items.iter().find_map(|i| match i { + Item::Tool { call_id, status, detail, .. } if call_id == id => Some((status.clone(), detail.clone())), + _ => None, + }).unwrap(); + assert_eq!(status(&m, "c1"), (ToolStatus::Running, "path: notes.md".into())); + m.apply("tool/progress", &json!({"turn_id": "t1", "tool_call_id": "c2", "message": "fetching"})); + assert_eq!(status(&m, "c2").1, "fetching"); + m.apply("tool/completed", &json!({"turn_id": "t1", "tool_call_id": "c1", "tool_name": "read_file", "success": true})); + m.apply("tool/completed", &json!({"turn_id": "t1", "tool_call_id": "c2", "tool_name": "web_fetch", "success": false, "output_preview": "404"})); + assert_eq!(status(&m, "c1").0, ToolStatus::Done); + assert_eq!(status(&m, "c2"), (ToolStatus::Failed, "404".into())); + // A tool still running when its turn ends did not finish. + m.apply("tool/started", &json!({"turn_id": "t1", "tool_call_id": "c3", "tool_name": "grep"})); + m.apply("turn/error", &json!({"turn_id": "t1", "code": "x", "message": "The provider refused."})); + assert_eq!(status(&m, "c3").0, ToolStatus::Failed); + assert!(matches!(m.items.last(), Some(Item::Notice(n)) if n == "The provider refused.")); +} + +#[test] +fn history_replaces_the_transcript_and_keeps_what_is_still_open() { + let mut m = ChatModel::new(); + m.notice("old"); + m.apply("user_question/requested", &json!({"turn_id": "t9", "question_id": "q1", "title": "Which?", "body": "b", + "questions": [{"header": "h", "question": "Which Edward?", "options": [{"label": "Edward A", "description": ""}, {"label": "Edward B", "description": ""}]}]})); + m.load_history(&json!([ + {"seq": 1, "role": "user", "content": "hello"}, + {"seq": 2, "role": "assistant", "content": "Hi there."}, + {"seq": 3, "role": "tool", "content": "{}", "name": "read_file"}, + {"seq": 4, "role": "system", "content": "ignored"}, + ])); + assert_eq!(text_of(&m, Role::User), ["hello"]); + assert_eq!(text_of(&m, Role::Assistant), ["Hi there."]); + assert!(m.items.iter().any(|i| matches!(i, Item::Tool { name, status: ToolStatus::Done, .. } if name == "read_file"))); + assert!(!m.items.iter().any(|i| matches!(i, Item::Notice(_))), "notices are not history"); + assert_eq!(m.open_question(), Some(("q1", 1)), "an open question survives a reload"); +} + +#[test] +fn an_approval_is_handed_on_never_answered_by_the_model() { + let mut m = ChatModel::new(); + m.start_turn("t1", "Email Ana the notes"); + let effects = m.apply("approval/requested", &json!({"turn_id": "t1", "approval_id": "a1", "tool_name": "write_file", + "title": "Write notes.md", "body": "outside the workspace"})); + let Some(Effect::Approval(ask)) = effects.first() else { panic!("{effects:?}") }; + assert_eq!(ask.plan, "Email Ana the notes", "the turn's prompt is the batch's plan"); + assert_eq!(ask.args, json!({"title": "Write notes.md", "body": "outside the workspace"})); + assert!(matches!(&m.items.last(), Some(Item::Approval { state: ApprovalState::Waiting, .. }))); + m.apply("approval/decided", &json!({"turn_id": "t1", "approval_id": "a1", "decision": "deny"})); + assert!(matches!(&m.items.last(), Some(Item::Approval { state: ApprovalState::Denied, .. }))); +} + +// ---------------------------------------------------------------- the driver + +#[test] +fn opening_resumes_the_system_session_and_loads_its_history() { + let (mut d, fake) = driver(); + fake.s().history = json!([{"seq": 1, "role": "user", "content": "earlier"}, {"seq": 2, "role": "assistant", "content": "Earlier answer."}]); + d.command(Command::Open); + settle(&mut d); + let open = fake.sent("session/open"); + assert_eq!(open, [json!({"session_id": SYSTEM_SESSION, "profile_id": "_main"})], "the one system conversation, no cwd, no token"); + assert_eq!(fake.sent("session/hydrate")[0]["include"], json!(["messages"])); + assert_eq!(text_of(&d.model, Role::Assistant), ["Earlier answer."]); + assert_eq!(d.model.phase(), &Phase::Ready); +} + +#[test] +fn a_turn_streams_and_interrupt_stops_it() { + let (mut d, fake) = opened(); + d.command(Command::Send(" Plan my Tuesday ".into())); + let start = fake.sent("turn/start"); + assert_eq!(start[0]["input"], json!([{"kind": "text", "text": "Plan my Tuesday"}])); + let turn = start[0]["turn_id"].as_str().unwrap().to_string(); + assert_eq!(turn.len(), 36, "a UUID turn id"); + fake.notify("message/delta", json!({"turn_id": turn, "text": "Looking"})); + fake.notify("tool/started", json!({"turn_id": turn, "tool_call_id": "c1", "tool_name": "peer_list"})); + settle(&mut d); + assert_eq!(text_of(&d.model, Role::Assistant), ["Looking"]); + assert_eq!(d.model.phase().running_turn(), Some(turn.as_str())); + // A second prompt waits for the first. + d.command(Command::Send("again".into())); + assert_eq!(fake.sent("turn/start").len(), 1); + d.command(Command::Interrupt); + assert_eq!(fake.sent("turn/interrupt"), [json!({"session_id": SYSTEM_SESSION, "turn_id": turn})]); + assert_eq!(d.model.phase(), &Phase::Ready); + assert!(d.model.items.iter().any(|i| matches!(i, Item::Tool { status: ToolStatus::Failed, .. })), "the running tool is marked stopped"); +} + +#[test] +fn a_question_is_answered_from_the_pane() { + let (mut d, fake) = opened(); + d.command(Command::Send("invite Edward".into())); + let turn = fake.sent("turn/start")[0]["turn_id"].clone(); + fake.notify("user_question/requested", json!({"turn_id": turn, "question_id": "q1", "title": "Which Edward?", "body": "b", + "questions": [{"header": "h", "question": "Which one?", "options": [{"label": "Edward A", "description": ""}]}]})); + settle(&mut d); + let (q, n) = d.model.open_question().map(|(q, n)| (q.to_string(), n)).unwrap(); + d.command(Command::Answer { question: q, count: n, text: "Edward A".into(), option: true }); + let respond = fake.sent("user_question/respond"); + assert_eq!(respond[0]["answers"], json!([{"selected_labels": ["Edward A"]}])); + assert_eq!(d.model.open_question(), None); +} + +#[test] +fn approvals_are_answered_only_with_the_routers_decision() { + let (mut d, fake) = opened(); + d.command(Command::Send("tidy my notes".into())); + let turn = fake.sent("turn/start")[0]["turn_id"].clone(); + fake.notify("approval/requested", json!({"turn_id": turn, "approval_id": "a1", "tool_name": "write_file", "title": "t", "body": "b"})); + settle(&mut d); + assert!(matches!(d.effects.as_slice(), [Effect::Approval(a)] if a.approval_id == "a1")); + assert!(fake.sent("approval/respond").is_empty(), "the chat never answers on its own"); + d.command(Command::Approval { approval_id: "a1".into(), approve: true }); + assert_eq!(fake.sent("approval/respond")[0]["decision"], "approve"); +} + +#[test] +fn new_conversation_clears_and_reloads() { + let (mut d, fake) = opened(); + d.model.notice("something"); + d.command(Command::NewConversation); + assert!(d.model.items.is_empty()); + assert_eq!(fake.sent("turn/start")[0]["input"][0]["text"], "/new", "the kernel's own new-conversation command, no model call"); + settle(&mut d); + assert_eq!(fake.sent("session/hydrate").len(), 2, "history reloaded after"); +} + +#[test] +fn a_kernel_restart_resumes_the_same_session() { + let (mut d, fake) = opened(); + d.command(Command::Send("long job".into())); + fake.s().close = Some(Closed { restarted: true, why: "restarted".into() }); + settle(&mut d); + assert!(matches!(d.model.phase(), Phase::Reconnecting(_)), "{:?}", d.model.phase()); + assert!(d.model.items.iter().any(|i| matches!(i, Item::Notice(n) if n.contains("restarted")))); + std::thread::sleep(Duration::from_millis(250)); + settle(&mut d); + assert_eq!(fake.s().connects, 2, "reconnected"); + assert_eq!(fake.sent("session/open").len(), 2, "reopened the same session"); + assert!(fake.sent("session/open").iter().all(|p| p["session_id"] == SYSTEM_SESSION)); + assert_eq!(d.model.phase(), &Phase::Ready); +} + +#[test] +fn no_provider_and_no_kernel_are_said_plainly() { + let (mut d, fake) = driver(); + fake.s().unavailable = Some(Unavailable::NoProvider); + d.command(Command::Open); + assert_eq!(d.model.phase(), &Phase::NoProvider); + // Sending waits for a provider rather than failing. + d.command(Command::Send("hello".into())); + assert!(fake.sent("turn/start").is_empty()); + let (mut d, fake) = driver(); + fake.s().unavailable = Some(Unavailable::NoKernel("no kernel binary".into())); + d.command(Command::Open); + assert_eq!(d.model.phase(), &Phase::NoKernel("no kernel binary".into())); + assert_eq!(super::view::phase_text(d.model.phase()), "The assistant isn't available on this device: no kernel binary."); +} + +#[test] +fn closing_the_pane_lets_the_kernel_go_unless_a_turn_runs() { + let (mut d, _fake) = opened(); + d.command(Command::Close); + assert!(!d.is_connected(), "idle: the kernel may stop"); + let (mut d, _fake) = opened(); + d.command(Command::Send("keep going".into())); + d.command(Command::Close); + assert!(d.is_connected(), "a running turn keeps its connection"); +} + +// ---------------------------------------------------------------- approvals + +#[test] +fn the_chats_approvals_reach_the_router_batched_per_request() { + use crate::approvals::{sheet::Place, Approvals, Route}; + let mut a = Approvals::memory(); + let ask = |id: &str| super::model::ApprovalAsk { + approval_id: id.into(), turn: "t1".into(), tool: "write_file".into(), title: "t".into(), body: "b".into(), + args: json!({"path": id}), plan: "Tidy my notes".into(), + }; + // What `route_approval` sends, on a router of our own. + for id in ["a1", "a2"] { + let ask = ask(id); + let context = crate::approvals::RequestContext { + call_id: format!("{}{}", super::HELD_PREFIX, ask.approval_id), + trigger: crate::approvals::Trigger::Person, + batch: Some(crate::approvals::Batch { id: format!("{}{}", super::HELD_PREFIX, ask.turn), plan: ask.plan.clone() }), + ..Default::default() + }; + let route = a.router.request(crate::approvals::router::make_request(super::APP, crate::approvals::ToolSpec::host(&ask.tool), ask.args.clone(), crate::approvals::Caller::SystemAgent, context, 1, 0), 1); + assert!(matches!(route, Route::Sheet(_)), "{route:?}"); + } + assert_eq!(a.router.sheets().len(), 1, "one sheet for the request"); + let sheet = &a.router.sheets()[0]; + assert!(matches!(&sheet.place, Place::SystemChat { plan, .. } if plan == "Tidy my notes")); + assert_eq!(sheet.lines.len(), 2); +} + +// ---------------------------------------------------------------- command execution + +#[test] +fn command_execution_needs_the_persons_gesture_and_changes_the_tool_set() { + let mut store = GrantStore::memory(); + assert!(!store.grants().command_execution, "off by default"); + assert!(store.tools().host_tools().is_empty()); + // No gesture, no grant. + assert!(store.set_command_execution(true, None).is_err()); + assert!(!store.grants().command_execution); + assert!(CommandGesture::settings_phrase("run commands").is_none()); + assert!(CommandGesture::settings_phrase("yes").is_none()); + let gesture = CommandGesture::settings_phrase(" Let the Assistant run commands ").expect("the typed phrase"); + store.set_command_execution(true, Some(gesture)).unwrap(); + assert!(store.tools().command_execution()); + assert!(store.tools().host_tools().contains(grants::COMMAND_TOOL), "terminal.run joins the system agent's set"); + #[cfg(kernel)] + assert!(!store.tools().names().contains("shell"), "never octos's shell"); + // Off needs nothing. + store.set_command_execution(false, None).unwrap(); + assert!(store.tools().host_tools().is_empty()); +} + +#[test] +fn the_switch_is_persisted_owner_only() { + let home = std::env::temp_dir().join(format!("syschat-grants-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&home); + let mut store = GrantStore::in_home(&home); + store.set_command_execution(true, CommandGesture::settings_phrase(grants::CONFIRM_PHRASE)).unwrap(); + assert!(GrantStore::in_home(&home).grants().command_execution); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(home.join(grants::GRANTS_FILE)).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + let _ = std::fs::remove_dir_all(home); +} + +#[test] +fn each_command_is_a_live_approval_with_the_exact_command() { + use crate::approvals::dev_hooks::FixedDevMode; + use crate::approvals::router::{make_request, AutoBy, Route}; + use crate::approvals::rules::{ApprovalGesture, RuleDraft}; + use crate::approvals::{Approvals, Caller, RequestContext, ToolSpec}; + // Not granted: refused before any sheet. + assert!(matches!(grants::request_command(false, "c0", "ls", None), Route::Refused(_))); + // Granted: a command no rule answers, even "everything for 60 min". + let mut a = Approvals::memory(); + a.router.create_rule(&ApprovalGesture::settings_tap(), RuleDraft::everything(grants::COMMAND_APP, 60), 1).unwrap(); + let req = |id: &str| make_request(grants::COMMAND_APP, ToolSpec::host(grants::COMMAND_TOOL).command(), json!({"command": "rm -rf build"}), + Caller::SystemAgent, RequestContext { call_id: id.into(), ..Default::default() }, 1, 0); + assert!(matches!(a.router.request(req("c1"), 1), Route::Sheet(_))); + let line = &a.router.front_sheet().unwrap().lines[0]; + assert!(line.args.iter().any(|l| l.contains("rm -rf build")), "the sheet shows the exact command: {:?}", line.args); + assert!(line.always.is_empty(), "no \u{201c}always\u{201d} for a command"); + // Developer mode still answers it (ADR 0004 §13). + a.router.set_hooks(Box::new(FixedDevMode::all())); + assert_eq!(a.router.request(req("c2"), 1), Route::Approved(AutoBy::DeveloperMode)); +} + +/// Only Settings makes a `CommandGesture`: no agent, app, bus call or +/// service can turn command execution on. +#[test] +fn only_the_settings_row_makes_a_command_gesture() { + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut makers = Vec::new(); + let mut stack = vec![src.clone()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).unwrap().flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path.extension().is_some_and(|e| e == "rs") { + let text = std::fs::read_to_string(&path).unwrap(); + let rel = path.strip_prefix(&src).unwrap().to_string_lossy().replace('\\', "/"); + if rel.starts_with("system_chat/") { + continue; + } + if text.contains("CommandGesture::") { + makers.push(rel); + } + } + } + } + assert_eq!(makers, ["lib.rs"], "{makers:?}"); + let lib = std::fs::read_to_string(src.join("lib.rs")).unwrap(); + let at = lib.find("CommandGesture::").unwrap(); + let handler = lib[..at].rfind("fn ").map(|i| &lib[i..at]).unwrap(); + assert!(handler.starts_with("fn assistant_commands_activate"), "only Settings makes one: {}", &handler[..60.min(handler.len())]); + assert!(!std::fs::read_to_string(src.join("ai_bus.rs")).unwrap().contains("set_command_execution")); +} + +// ---------------------------------------------------------------- a real kernel + +/// A system-agent turn from the pane's driver against a real `octos` +/// kernel and a scripted model (`crates/app-peers/tests/fixtures/mock_llm.py`, +/// no network, no keys): the answer streams in, the history reloads it, a +/// kernel restart resumes the same conversation. Runs when +/// `OCTOS_CORE_TEST_KERNEL` names an `octos` binary (see +/// crates/kernel/tests/real_kernel.rs); otherwise it says so and passes. +#[cfg(kernel)] +#[test] +fn a_system_agent_turn_from_the_pane_on_a_real_kernel() { + use octosense_ai_host::kernel::{Core, Options}; + use std::io::BufRead; + let Some(program) = std::env::var_os("OCTOS_CORE_TEST_KERNEL") else { + eprintln!("OCTOS_CORE_TEST_KERNEL is not set: skipping the real-kernel chat test"); + return; + }; + let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../app-peers/tests/fixtures/mock_llm.py"); + let mut model = std::process::Command::new("python3").arg(script).stdout(std::process::Stdio::piped()).spawn().unwrap(); + let mut line = String::new(); + std::io::BufReader::new(model.stdout.take().unwrap()).read_line(&mut line).unwrap(); + let port: u16 = line.trim().parse().unwrap(); + let dir = std::env::temp_dir().join(format!("syschat-real-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("profiles")).unwrap(); + std::fs::write(dir.join("profiles/_main.json"), json!({ + "id": "_main", "name": "Main", "enabled": true, + "created_at": "2026-09-28T00:00:00Z", "updated_at": "2026-09-28T00:00:00Z", + "config": {"llm": {"primary": {"family_id": "local", "model_id": "mock-model", + "route": {"base_url": format!("http://127.0.0.1:{port}/v1"), "api_type": "openai"}}}} + }).to_string()).unwrap(); + assert!(super::provider_configured(Some(&dir.join("profiles/_main.json")))); + let core = Core::new(Options::default().program(std::path::PathBuf::from(program)).core_dir(&dir)); + struct CoreConnector(Core); + impl Connector for CoreConnector { + fn connect(&mut self) -> Result, Unavailable> { + self.0.connect().map(super::link::link).map_err(|e| Unavailable::Failed(e.to_string())) + } + } + let mut d = Driver::new(Box::new(CoreConnector(core.clone()))); + let until = |d: &mut Driver, what: &str, done: &dyn Fn(&Driver) -> bool| { + let deadline = std::time::Instant::now() + Duration::from_secs(60); + while !done(d) { + assert!(std::time::Instant::now() < deadline, "timed out waiting for {what}: {:?} {:?}", d.model.phase(), d.model.items); + d.step(Duration::from_millis(100)); + } + }; + d.command(Command::Open); + until(&mut d, "the session", &|d| d.model.phase() == &Phase::Ready); + d.command(Command::Send("hello from the pane".into())); + until(&mut d, "the answer", &|d| d.model.phase() == &Phase::Ready && text_of(&d.model, Role::Assistant).iter().any(|t| t.contains("ECHO: hello from the pane"))); + // The kernel restarts (a provider change, Settings' restart): the chat + // reconnects and the same conversation's history comes back. + assert!(core.restart()); + until(&mut d, "the reconnect", &|d| matches!(d.model.phase(), Phase::Reconnecting(_) | Phase::Connecting)); + until(&mut d, "the resumed session", &|d| d.model.phase() == &Phase::Ready && text_of(&d.model, Role::User).iter().any(|t| t == "hello from the pane") + && text_of(&d.model, Role::Assistant).iter().any(|t| t.contains("ECHO: hello from the pane"))); + d.command(Command::Send("second".into())); + until(&mut d, "the second answer", &|d| d.model.phase() == &Phase::Ready && text_of(&d.model, Role::Assistant).iter().any(|t| t.contains("ECHO: second"))); + drop(d); + core.shutdown_within(Duration::from_secs(10)); + let _ = model.kill(); + let _ = model.wait(); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/shell/src/system_chat/view.rs b/crates/shell/src/system_chat/view.rs new file mode 100644 index 00000000..5caff9d3 --- /dev/null +++ b/crates/shell/src/system_chat/view.rs @@ -0,0 +1,403 @@ +//! The system chat pane, drawn by the shell like its other surfaces +//! (approvals/view.rs, glance_panel.rs): a column at the right of the +//! desktop, the whole screen on a phone (or any narrow window). +//! +//! It shows the conversation ([`super::model`]): the person's messages, +//! the assistant's streamed text, each tool call with its status, the +//! approvals the shell's sheet holds (a note only: the sheet answers them), +//! notices, and the open question with its options. Below it the prompt, +//! Send (Stop while a turn runs); above it New conversation and Close. +//! +//! The shell gives it pointer events first while it is open +//! ([`ShellSystemChat::pointer`]) and the keyboard (`super::key`). + +use makepad_widgets::*; + +use super::model::{ApprovalState, ChatModel, Item, Phase, Role, ToolStatus}; +use crate::approvals::view::Buttons; +use crate::shell::ui::{contains, rect, DrawShellFill, HAlign, ShellDraw}; +use crate::shell::{alpha, ShellTokens}; + +script_mod! { + use mod.prelude.widgets_internal.* + use mod.widgets.* + + mod.widgets.ShellSystemChatBase = #(ShellSystemChat::register_widget(vm)) + mod.widgets.ShellSystemChat = set_type_default() do mod.widgets.ShellSystemChatBase { + width: Fill + height: Fill + draw_bg +: {} + d +: {} + } +} + +/// The desktop column's width. +pub const PANE_W: f64 = 440.0; +/// Narrower than this, the pane takes the whole screen (the phone surface). +pub const FULL_SCREEN_BELOW: f64 = 720.0; +const PAD: f64 = 16.0; +const FIELD_H: f64 = 36.0; + +#[derive(Clone, Debug, PartialEq)] +enum Hit { + Close, + New, + Send, + Stop, + Option { question: String, count: usize, label: String }, + OpenProviders, + Field, + Pane, +} + +/// What a pointer event did. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Outcome { + /// Not the pane's. + Ignored, + Taken, + /// "Open AI providers": the shell launches it. + OpenProviders, +} + +/// The line of text the header shows for a phase. +pub fn phase_text(phase: &Phase) -> String { + match phase { + Phase::Idle | Phase::Connecting => "Connecting to the assistant\u{2026}".into(), + Phase::NoKernel(why) => format!("The assistant isn't available on this device: {why}."), + Phase::NoProvider => "No model provider is set up yet. Add one in AI providers.".into(), + Phase::Ready => "The system agent \u{00b7} ready".into(), + Phase::Running { .. } => "Working\u{2026}".into(), + Phase::Reconnecting(why) => format!("{why}; reconnecting\u{2026}"), + } +} + +/// One drawn line of the transcript. +#[derive(Clone, Debug, PartialEq)] +struct Line { + text: String, + bold: bool, + small: bool, + dim: bool, + accent: bool, + gap_before: f64, +} + +#[derive(Script, ScriptHook, Widget)] +pub struct ShellSystemChat { + #[uid] + uid: WidgetUid, + #[source] + source: ScriptObjectRef, + #[walk] + walk: Walk, + #[layout] + layout: Layout, + #[redraw] + #[live] + draw_bg: DrawShellFill, + #[live] + d: ShellDraw, + #[live] + tokens: ShellTokens, + #[rust] + area: Area, + #[rust] + hits: Vec<(Rect, Hit)>, + #[rust] + pane: Rect, + #[rust] + down: Option, + #[rust] + hover: Option, + /// How far the transcript can scroll back. + #[rust] + max_scroll: f64, + /// What the last frame showed, one string per line (for tests and the + /// hidden-window runs' logs). + #[rust] + pub shown: Vec, +} + +impl ShellSystemChat { + fn hit_at(&self, p: Vec2d) -> Option { + self.hits.iter().find(|(r, h)| *h != Hit::Pane && contains(*r, p)).or_else(|| self.hits.iter().find(|(r, _)| contains(*r, p))).map(|(_, h)| h.clone()) + } + + /// The shell's pointer hook: the pane's own rect is its own while open. + pub fn pointer(&mut self, cx: &mut Cx, event: &Event) -> Outcome { + if !super::is_open() || self.pane.size.x <= 0.0 { + return Outcome::Ignored; + } + match event { + Event::Scroll(e) if contains(self.pane, e.abs) => { + super::scroll_by(e.scroll.y, self.max_scroll); + self.redraw(cx); + return Outcome::Taken; + } + Event::MouseMove(e) => { + let hover = self.hits.iter().find(|(r, h)| *h != Hit::Pane && contains(*r, e.abs)).map(|(r, _)| *r); + if hover != self.hover { + self.hover = hover; + self.redraw(cx); + } + return if contains(self.pane, e.abs) { Outcome::Taken } else { Outcome::Ignored }; + } + _ => {} + } + let (down, up) = match event { + Event::MouseDown(e) => (Some(e.abs), None), + Event::MouseUp(e) => (None, Some(e.abs)), + Event::TouchUpdate(e) => ( + e.touches.iter().find(|t| t.state == makepad_widgets::makepad_platform::event::TouchState::Start).map(|t| t.abs), + e.touches.iter().find(|t| t.state == makepad_widgets::makepad_platform::event::TouchState::Stop).map(|t| t.abs), + ), + _ => (None, None), + }; + if let Some(p) = down { + if !contains(self.pane, p) { + return Outcome::Ignored; + } + self.down = self.hit_at(p); + if self.down == Some(Hit::Field) { + cx.show_text_ime(self.area, dvec2(self.pane.pos.x + PAD, self.pane.pos.y + self.pane.size.y - PAD - FIELD_H)); + } + return Outcome::Taken; + } + if let Some(p) = up { + let hit = self.hit_at(p); + let pressed = self.down.take(); + if hit.is_none() || hit != pressed { + return if contains(self.pane, p) { Outcome::Taken } else { Outcome::Ignored }; + } + let outcome = act(hit.unwrap()); + self.redraw(cx); + return outcome; + } + Outcome::Ignored + } + + fn transcript(&mut self, cx: &mut Cx2d, model: &ChatModel, width: f64, tok: &ShellTokens) -> Vec { + let body = tok.font.body; + let small = tok.font.body_small; + let mut lines = Vec::new(); + let running = model.phase().running_turn().is_some(); + let last = model.items.len().saturating_sub(1); + let push_wrapped = |d: &mut ShellDraw, cx: &mut Cx2d, lines: &mut Vec, text: &str, small_text: bool, dim: bool, accent: bool, gap: f64| { + let px = if small_text { small } else { body }; + let mut first = true; + for para in text.split('\n') { + let wrapped = if para.trim().is_empty() { vec![String::new()] } else { d.wrap(cx, false, px, para, width, 400) }; + for l in wrapped { + lines.push(Line { text: l, bold: false, small: small_text, dim, accent, gap_before: if first { gap } else { 0.0 } }); + first = false; + } + } + }; + for (i, item) in model.items.iter().enumerate() { + match item { + Item::Message { role, text, .. } => { + let who = match role { + Role::User => "You", + Role::Assistant => "Assistant", + }; + lines.push(Line { text: who.into(), bold: true, small: true, dim: *role == Role::User, accent: false, gap_before: 12.0 }); + let mut text = text.clone(); + if running && i == last && *role == Role::Assistant { + text.push('\u{258d}'); + } + push_wrapped(&mut self.d, cx, &mut lines, &text, false, false, false, 2.0); + } + Item::Tool { name, status, detail, .. } => { + let state = match status { + ToolStatus::Running => "running\u{2026}", + ToolStatus::Done => "done", + ToolStatus::Failed => "failed", + }; + let mut text = format!("\u{2699} {name} \u{00b7} {state}"); + if !detail.is_empty() { + text.push_str(&format!(" \u{00b7} {detail}")); + } + push_wrapped(&mut self.d, cx, &mut lines, &text, true, true, false, 6.0); + } + Item::Approval { tool, title, state, .. } => { + let state = match state { + ApprovalState::Waiting => "waiting for you on the approval sheet", + ApprovalState::Approved => "approved", + ApprovalState::Denied => "denied", + ApprovalState::Cancelled => "withdrawn", + }; + let what = if title.is_empty() { tool.clone() } else { format!("{tool}: {title}") }; + push_wrapped(&mut self.d, cx, &mut lines, &format!("\u{2691} Approval \u{00b7} {what} \u{00b7} {state}"), true, false, true, 6.0); + } + Item::Question { title, body: q, answered, .. } => { + let head = if title.is_empty() { "Question".to_string() } else { title.clone() }; + push_wrapped(&mut self.d, cx, &mut lines, &format!("? {head}: {q}"), false, false, true, 10.0); + if let Some(a) = answered { + push_wrapped(&mut self.d, cx, &mut lines, &format!("You answered: {a}"), true, true, false, 2.0); + } + } + Item::Notice(text) => push_wrapped(&mut self.d, cx, &mut lines, text, true, true, false, 8.0), + } + } + lines + } + + fn draw_pane(&mut self, cx: &mut Cx2d, screen: Rect) { + self.hits.clear(); + self.shown.clear(); + if !super::is_open() { + self.pane = Rect::default(); + return; + } + let tok = self.d.tokens(self.tokens); + let full = screen.size.x < FULL_SCREEN_BELOW; + let pane = if full { + screen + } else { + let gap = tok.spacing.gaps_out; + rect(screen.pos.x + screen.size.x - gap - PANE_W, screen.pos.y + gap, PANE_W, (screen.size.y - gap * 2.0).max(240.0)) + }; + self.pane = pane; + self.d.card(cx, pane, &tok.popups); + self.hits.push((pane, Hit::Pane)); + let ink = tok.popups.text; + let dim = alpha(ink, 0.62); + let accent = tok.notifications.countdown; + let x = pane.pos.x + PAD; + let cw = pane.size.x - PAD * 2.0; + let mut hits = Vec::new(); + let mut shown = Vec::new(); + let model = super::snapshot(); + let draft = super::draft(); + let hover = self.hover; + + // Header. + let mut y = pane.pos.y + PAD; + { + let mut b = Buttons { d: &mut self.d, tok, hover }; + let close_w = b.width(cx, "Close"); + let close = b.draw(cx, x + cw - close_w, y, close_w, "Close", false); + hits.push((close, Hit::Close)); + let new_w = b.width(cx, "New conversation"); + let new = b.draw(cx, close.pos.x - 8.0 - new_w, y, new_w, "New conversation", false); + hits.push((new, Hit::New)); + b.d.label_elided(cx, rect(x, y, new.pos.x - x - 8.0, 24.0), true, tok.font.heading, ink, HAlign::Left, "Assistant"); + } + shown.push("Assistant".to_string()); + y += 30.0; + let status = phase_text(model.phase()); + self.d.label_elided(cx, rect(x, y, cw, 16.0), false, tok.font.body_small, dim, HAlign::Left, &status); + shown.push(status); + y += 22.0; + self.d.separator(cx, rect(x, y, cw, 1.0), ink, 0.12); + let top = y + 6.0; + + // Composer at the bottom. + let bottom = pane.pos.y + pane.size.y - PAD; + let field_y = bottom - FIELD_H; + let running = model.phase().running_turn().is_some(); + let (label, hit) = if running { ("Stop", Hit::Stop) } else { ("Send", Hit::Send) }; + let usable = matches!(model.phase(), Phase::Ready | Phase::Running { .. }); + { + let mut b = Buttons { d: &mut self.d, tok, hover }; + let bw = b.width(cx, label); + let button = b.draw(cx, x + cw - bw, field_y + (FIELD_H - 28.0) * 0.5, bw, label, usable && (running || !draft.trim().is_empty())); + hits.push((button, hit)); + let field = rect(x, field_y, cw - bw - 8.0, FIELD_H); + let placeholder = if model.open_question().is_some() { "Answer the question\u{2026}" } else { "Ask the system agent\u{2026}" }; + b.d.text_field(cx, field, &tok, &draft, placeholder, true, hover == Some(field), ink); + hits.push((field, Hit::Field)); + } + shown.push(format!("prompt: {draft}")); + let mut list_bottom = field_y - 10.0; + + // The open question's options, over the composer. + if let Some(Item::Question { id, options, count, answered: None, .. }) = model.items.iter().rev().find(|i| matches!(i, Item::Question { answered: None, .. })) { + if !options.is_empty() { + let mut b = Buttons { d: &mut self.d, tok, hover }; + let row_y = list_bottom - 30.0; + let mut ox = x; + for label in options { + let w = b.width(cx, label).min(cw); + if ox + w > x + cw { + break; + } + let r = b.draw(cx, ox, row_y, w, label, false); + hits.push((r, Hit::Option { question: id.clone(), count: *count, label: label.clone() })); + shown.push(format!("option: {label}")); + ox += w + 8.0; + } + list_bottom = row_y - 8.0; + } + } + + // No provider: say so, with the way to fix it. + if model.phase() == &Phase::NoProvider { + let mut b = Buttons { d: &mut self.d, tok, hover }; + let w = b.width(cx, "Open AI providers"); + let r = b.draw(cx, x, top + 12.0, w, "Open AI providers", true); + hits.push((r, Hit::OpenProviders)); + shown.push("Open AI providers".into()); + } + + // The transcript, newest at the bottom, scrolled back by `scroll`. + let lines = self.transcript(cx, &model, cw, &tok); + let heights: Vec = lines.iter().map(|l| l.gap_before + if l.small { tok.font.body_small * 1.45 } else { tok.font.body * 1.45 }).collect(); + let total: f64 = heights.iter().sum(); + let room = (list_bottom - top).max(0.0); + self.max_scroll = (total - room).max(0.0); + let scroll = super::scroll().min(self.max_scroll); + let mut ly = list_bottom - total + scroll; + for (line, h) in lines.iter().zip(&heights) { + let line_top = ly + line.gap_before; + ly += h; + if line_top < top || ly > list_bottom + 0.5 { + continue; + } + let px = if line.small { tok.font.body_small } else { tok.font.body }; + let color = if line.accent { accent } else if line.dim { dim } else { ink }; + self.d.label_elided(cx, rect(x, line_top, cw, h - line.gap_before), line.bold, px, color, HAlign::Left, &line.text); + } + if model.items.is_empty() && usable { + let hint = "Ask the system agent anything: it can read and write its own workspace, search the web and brief your apps' agents. It asks you before anything outward."; + let wrapped = self.d.wrap(cx, false, tok.font.body_small, hint, cw, 6); + let mut hy = top + 12.0; + for l in wrapped { + self.d.label_elided(cx, rect(x, hy, cw, 18.0), false, tok.font.body_small, dim, HAlign::Left, &l); + hy += 18.0; + } + } + shown.extend(lines.iter().map(|l| l.text.clone())); + self.hits.extend(hits); + self.shown = shown; + } +} + +/// What a press does. +fn act(hit: Hit) -> Outcome { + match hit { + Hit::Close => super::close(), + Hit::New => super::new_conversation(), + Hit::Send => super::send_draft(), + Hit::Stop => super::interrupt(), + Hit::Option { question, count, label } => super::answer_option(&question, count, &label), + Hit::OpenProviders => return Outcome::OpenProviders, + Hit::Field | Hit::Pane => {} + } + Outcome::Taken +} + +impl Widget for ShellSystemChat { + fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep { + cx.begin_turtle(walk, self.layout); + let screen = cx.turtle().rect(); + self.d.begin_surface(cx); + self.draw_pane(cx, screen); + self.d.end_surface(cx); + cx.end_turtle_with_area(&mut self.area); + DrawStep::done() + } + + fn handle_event(&mut self, _cx: &mut Cx, _event: &Event, _scope: &mut Scope) {} +} diff --git a/docs/adr/0004-native-apps-hosting-and-peers.md b/docs/adr/0004-native-apps-hosting-and-peers.md index 22a194ea..e445ad3c 100644 --- a/docs/adr/0004-native-apps-hosting-and-peers.md +++ b/docs/adr/0004-native-apps-hosting-and-peers.md @@ -327,9 +327,9 @@ Gaps between this decision and `main` found after it was accepted (2026-09-28; s - **`dev.run` registration** (section 13) waits on octos#2567's host tool registration. - **Developer mode per app** (section 13): choosing which apps it covers in Settings (today only through `OCTOSENSE_DEV_MODE`), and the phone gesture that turns it on. - **The system agent's exact tool list** (section 12, step 4): the `_main` profile's `tool_policy` is a ceiling, not the list; the exact list needs octos#2567's session tool lists (its review item M1). -- **The Settings switch for the system agent's command execution** (section 12): `SystemAgentTools::grant_command_execution` exists, but no Settings switch sets it, and its host tool needs octos#2567. +- [x] **The Settings switch for the system agent's command execution** (section 12): done. Setup → Assistant → Command execution sets `SystemAgentTools::grant_command_execution` with the person's typed confirmation, applies from the next kernel start (with a restart offered), and routes each command through the approval router as `auto_approvable: false`. Registering its host tool on the system session still needs octos#2567. - **The agent workspace is the account folder** (section 11): `peer/prepare` is to get the account folder as its `cwd` (today the broker sends none and the peer uses the kernel-provisioned workspace). The account folder name (SHA-256, `app_storage`'s `account_hash`) and the memory namespace tag (FNV-1a, `account_tag` in the `crates/app-peers` broker) are two different hashes of one account; one should derive from the other. Changing the memory tag re-keys every app's memory, so it needs a migration (or the folder name follows the tag). -- **A shell-native chat with the system agent**: today only a paired Talk to Octos client reaches it; the desktop's AI pane is Makepad's `aichat`, not the system agent. +- [x] **A shell-native chat with the system agent**: done. `crates/shell/src/system_chat/` (Setup → Assistant → Assistant chat, F8; full screen on a phone) talks to `_main:api:octosense#system` over the shell's own kernel connection, and its approvals go through the approval router, batched per request. ## Open questions