From ec13489a70edbf13292058f6b1e7b12316cb17b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 23:36:10 +0000 Subject: [PATCH 01/17] Lay Sonny's work out in cmux Sonny run in a cmux tab now makes the project's cmux workspace its home, as it does a Herdr workspace or a Rex session: each task the lead starts gets a tab in the lead's pane running Sonny on it, titled with the bot's name; run_in_pane and show_agent open tabs and splits there; another project's lead gets a workspace of its own. Every call goes through `cmux rpc` (system.tree, workspace.create, surface.create, surface.split, surface.close, tab.action), and the workspace is kept in ~/.agent/sonny/places so the lookout's calls land in it. cmux already reads the OSC 7501 status and OSC 777 notifications Sonny writes, so its sidebar shows each task's state and rings the tab that needs the person. Each Sonny also gives its tab a resume command (`cmux surface resume set`, `Sonny: NAME`), so cmux reopens it after a relaunch once the person allows `sonny` commands; quitting clears it. Agents get a cmux-tabs skill like herdr-panes, and the daemon keeps a cmux tab's own place variables (CMUX_WORKSPACE_ID, CMUX_SURFACE_ID, CMUX_TAB_ID, CMUX_PANEL_ID) out of its tools' shells, as it does Herdr's. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFMvxuKK3qzEb4j48d --- README.md | 2 +- docs/SONNY.md | 4 +- sonny/README.md | 45 +- sonny/skills/cmux-tabs/SKILL.md | 45 ++ sonny/src/cmux.rs | 821 ++++++++++++++++++++++++++++++++ sonny/src/herdr.rs | 2 +- sonny/src/main.rs | 9 +- sonny/src/mux.rs | 133 ++++-- sonny/src/project.rs | 4 +- src/tools.rs | 4 + tests/test_approvals.py | 9 +- 11 files changed, 1003 insertions(+), 75 deletions(-) create mode 100644 sonny/skills/cmux-tabs/SKILL.md create mode 100644 sonny/src/cmux.rs diff --git a/README.md b/README.md index 663c21609..3a0303cf1 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ protocol directly; [`client/`](client) is a Rust client for it. See the [Sonny](sonny/README.md) is a terminal client over the same socket. Opened in a folder, it is that project's lead: it starts tasks, each in its own copy -of the folder, lays them out in Rex or Herdr, and hears what they did. It +of the folder, lays them out in Rex, Herdr or cmux, and hears what they did. It also gives every agent [triggers, memory and skills](docs/SONNY.md). ```sh diff --git a/docs/SONNY.md b/docs/SONNY.md index 94862b519..db59c7f49 100644 --- a/docs/SONNY.md +++ b/docs/SONNY.md @@ -358,8 +358,8 @@ At each start Sonny writes the skills it ships into `~/.agents/skills`, where every agent finds them ([client policy](CLIENT.md)): `plan`, an agent's plan as a file; `memory`, what to save for later agents; `memory-cleanup`, the nightly cleanup (see Memory); `automation`, for recurring jobs (see -Triggers); and, depending on where it runs, `pictures`, `rex-windows` or -`herdr-panes`, and `errand`. They are normal skills, real files +Triggers); and, depending on where it runs, `pictures`, `rex-windows`, +`herdr-panes` or `cmux-tabs`, and `errand`. They are normal skills, real files ([skills.rs](../sonny/src/skills.rs)). Sonny notes a fingerprint of each file it writes in `~/.agent/sonny/skills` and rewrites or removes a skill only while it is as Sonny last wrote it: one the person edited, or one of that diff --git a/sonny/README.md b/sonny/README.md index 018d612ec..d3d1337b2 100644 --- a/sonny/README.md +++ b/sonny/README.md @@ -1,8 +1,8 @@ # Sonny (prototype) -A terminal client for the daemon that lays its work out in Rex or Herdr. It -speaks the daemon's socket protocol through `agent-client`; nothing in the -daemon or CLI knows it exists. +A terminal client for the daemon that lays its work out in Rex, Herdr or +cmux. It speaks the daemon's socket protocol through `agent-client`; nothing +in the daemon or CLI knows it exists. brew install lydakis/agent/sonny # Sonny, and the daemon's `agent` formula with it brew install lydakis/agent/agent # just the daemon @@ -111,13 +111,15 @@ lead's effort after it is made. A lead moved to another folder (`--workspace`) comes back with the next message Sonny sends it from its own, history and all. When Sonny runs in a Rex block (`rex` found as -`$REX`, on the PATH, or in the Mac app) or a Herdr pane (`herdr` found as -`$HERDR` or on the PATH), the project gets one home there: in Rex a session -(the one Sonny was opened in, else the one the lead is in, else one labelled -with the project's name), in Herdr a workspace, chosen the same way. Each task -the lead (or one of its tasks) starts gets a window (a Herdr tab) there -running Sonny on that task, without taking focus, its block (pane) labelled -with the task's name. In Rex, each window reports OSC 7501 status, so Rex +`$REX`, on the PATH, or in the Mac app), a Herdr pane (`herdr` found as +`$HERDR` or on the PATH) or a cmux tab (`cmux` found as the tab's own CLI, +on the PATH, or in the Mac app), the project gets one home there: in Rex a +session (the one Sonny was opened in, else the one the lead is in, else one +labelled with the project's name), in Herdr or cmux a workspace, chosen the +same way. Each task the lead (or one of its tasks) starts gets a window (a +Herdr tab; in cmux a tab in the lead's pane) there running Sonny on that +task, without taking focus, its block (pane, cmux tab) labelled with the +task's name. In Rex, each window reports OSC 7501 status, so Rex shows which task is working, waiting on an approval or done. In Herdr, Sonny supports Herdr the way its own agents do: each Sonny reports its bot through `herdr pane report-agent`, so the agent panel lists it as `Sonny: NAME` @@ -130,18 +132,28 @@ pane of the Herdr server on its own machine: one reached by plain `ssh` from a Herdr pane is not listed, one in a remote machine Herdr itself connects to is. The daemon, lookout and approver Sonny starts run in a session of their own, so closing their pane or stopping Herdr leaves them and their turns running. +In cmux, which reads OSC 7501 and OSC 777 itself, the workspace's sidebar row +shows which task is working, waiting on an approval or done, and a +notification rings the tab it came from. Each Sonny gives its tab a resume +command (`cmux surface resume set`, shown as `Sonny: NAME`), so after cmux +relaunches the tab runs `sonny` on the same bot again, once you allow +commands starting with `sonny` from the tab's Resume Commands menu (until +then cmux offers it to run by hand); quitting Sonny clears it. cmux lets +only programs started in its tabs reach it, by default: the lookout Sonny +starts there is one, and one started outside cmux needs Settings > +Automation set to allow local automation. The place a project was laid out in is kept in `~/.agent/sonny/places`, so an agent's -`run_in_pane` and `show_agent` calls go to that Herdr workspace or Rex -session, in that multiplexer, rather than one guessed from where the +`run_in_pane` and `show_agent` calls go to that Herdr or cmux workspace or +Rex session, in that multiplexer, rather than one guessed from where the lookout answering them runs, which is in neither. A task's window closes once its work is taken: its turn completed and the lead (or the task that started it) waited on it. One that failed, stopped or waits on you stays open, and a task given more work opens again; ask the lead to show one, or run `sonny --bot NAME`. `--rex-host HOST` puts the session on another Rex server; `--no-layout` turns the layout off. Sonny writes the skill for the -multiplexer it runs in to `~/.agents/skills` (`rex-windows` or -`herdr-panes`, from `skills/`), so any agent can run a long command where +multiplexer it runs in to `~/.agents/skills` (`rex-windows`, +`herdr-panes` or `cmux-tabs`, from `skills/`), so any agent can run a long command where the person can watch it, and read it back: the `run_in_pane` tool (`name`, `command`, `split` right, left, above or below) opens a block labelled `AGENT/NAME` in a tab of its own (or on that side of the agent's @@ -157,8 +169,9 @@ or its tasks, out in this one's home. `sonny --bot PROJECT.lead` anywhere is that lead's Sonny too, laying out its tasks where it runs. Opened in a session that already holds other work, Sonny takes that session as the project's home and leaves the rest alone: tasks get tabs of their own, and a -tab that holds the person's own panes keeps its name. Herdr has no exit status to wait -for, so its skill has agents end such a command with a line to wait for. +tab that holds the person's own panes keeps its name. Herdr and cmux have no +exit status to wait for, so their skills have agents end such a command with +a line to wait for. The skill tells agents to close what they opened once read; whatever a task left open closes with its window. diff --git a/sonny/skills/cmux-tabs/SKILL.md b/sonny/skills/cmux-tabs/SKILL.md new file mode 100644 index 000000000..7dead625a --- /dev/null +++ b/sonny/skills/cmux-tabs/SKILL.md @@ -0,0 +1,45 @@ +--- +name: cmux-tabs +description: Use this, not the cmux skill, whenever the person mentions cmux or a tab, pane or split. Run long or interactive commands (dev servers, watchers, builds or test runs past the shell tool's 10 minutes, REPLs) in a cmux tab the person can watch, then read, wait on or stop them; and put an agent's tab where the person asks (its own tab, or a split beside another). +--- + +cmux is the terminal the person watches the work in. Your shell is not in a cmux tab, by design: it runs in the agent daemon, so `CMUX_SURFACE_ID`, `CMUX_WORKSPACE_ID` and the other tab variables are unset. That does not mean the person is outside cmux, and it is no reason to stop; this skill's commands work from your shell. Each project has one cmux workspace: the one its lead runs in. In it, every agent has a tab titled with its full name (`shop.fix-cart`). Use a tab only for a command that runs longer than the shell tool allows (up to 10 minutes with `timeout_ms`), that keeps running (a server, a watcher), or that the person wants to watch. Everything else, a one-minute test run or build included, stays in the shell tool: give it a `timeout_ms` that covers it rather than a short one, since one shell call is faster than a tab, a wait and a read. + +## Tabs or a split + +A tab is the default. It is one more stream of work, beside the lead's tab, that the person can click into when they choose, to watch it, steer it and manage it there. A split puts something beside you, in the workspace the person is looking at, so it asks for their attention now. Open one only when you want them to see something right away: a server or a CLI's output you are showing them, or an agent they should watch or talk to side by side with you. When unsure, use a tab. + +Workspaces are for leads: each project's lead has a cmux workspace of its own, and its agents and commands get tabs and splits in it. + +## Where things go + +- An agent you start gets a tab of its own when its turn starts, without your doing anything. +- A command of yours that belongs in a tab (a dev server, a test watcher, a build or test run over 10 minutes, a log tail, a REPL) goes in one of its own with the `run_in_pane` tool, named for what it is (`dev-server`, `tests`), not a number. A tab is the default; `split` (right, left, above, below) puts it beside you, to show it now. + + It runs as `sh -c` runs the command, in your folder (or `cwd`), in the person's shell, which stays open after the command exits so the output can be read. The result holds the tab's ID as `block`; keep it. The tab is titled `YOUR-NAME/NAME`, so the person can see whose it is. Running the same name again replaces the old tab. Your shell's variables are not there, so put what the command needs in it: `env VAR=value COMMAND`. To wait for the command to end, end it with a line you can wait for: `cargo test; echo "exited $?"`. + +- Never make a cmux workspace, tab or split yourself (`cmux new-workspace`, `new-surface`, `new-split`, `new-pane`), and never leave out `--surface`: without it, cmux picks a tab that may not be yours. +- Never close a tab the person has typed in or asked you to open; ask them first. Close only what you opened for your own work. + +- When the person asks for an agent somewhere else ("put it next to the lead", "open that in a split", "give it its own tab"), or they should watch one beside you now, move it with the `show_agent` tool, before or after starting it: `split` puts it on that side of you, or of the agent `beside` names, and without `split` it gets its own tab again. + +- Another project's lead you hand work to (`PROJECT.lead`) has a cmux workspace of its own, not a tab in yours. When the person wants to watch it, or you start a lead that is not open anywhere yet, open it there with `show_agent` and no `split`; one already open is left as it is. + +## cmux knows you + +cmux's sidebar shows what each agent Sonny runs is doing, you included (working, waiting for the person, done), and rings the tab of one that needs them; Sonny tells cmux that for you, so never do it yourself (`cmux set-status`, `set-progress`, `log`, `notify`). Agents the person runs in cmux outside Sonny (Claude Code, Codex and others) are tabs too. To see every tab: `CMUX_BIN tree --all`; read one by its ID as below. Type into another agent's tab only when the person asks you to. + +## Working with a tab + +- Read its output: `CMUX_BIN read-screen --surface ID --lines 120` +- Wait for a line, polling, inside one shell call with a `timeout_ms` that covers it: `until CMUX_BIN read-screen --surface ID --lines 40 | grep -Eq '^exited [0-9]+$'; do sleep 5; done` +- Type into it and press Enter: `CMUX_BIN send --surface ID 'text\n'`; `CMUX_BIN send-key --surface ID ctrl+c` stops what runs +- Close it: `CMUX_BIN close-surface --surface ID --force` (`--force` closes it while a program still runs in it) + +## Cleaning up + +Close each tab you opened once you have what you need from it: a build or test run once you have read its result, a server once nothing needs it. Leave one open only when the person should see it or another agent still reads it, and say so in your reply with its title. When an agent's work is taken, its tab closes, and every tab it opened closes with it. + +## When cmux refuses + +cmux lets only programs it started, or ones the person allows in cmux's Settings > Automation, reach it. When a call says cmux refused or denied the connection, tell the person, with what cmux said; never change cmux's settings yourself. diff --git a/sonny/src/cmux.rs b/sonny/src/cmux.rs new file mode 100644 index 000000000..ca45e6b7c --- /dev/null +++ b/sonny/src/cmux.rs @@ -0,0 +1,821 @@ +//! cmux (cmux.com) as the place a project's work is laid out: one cmux +//! workspace per project, and in it a tab for each task the lead starts, +//! beside the lead's own tab, titled with the bot's name. cmux's tabs are +//! its surfaces, kept in the panes a workspace is split into, so a task's +//! tab goes in the lead's pane and a split makes a pane of its own. Every +//! call goes through `cmux rpc`, which prints the socket API's JSON result; +//! ids are kept because titles need not be unique. Written against cmux +//! 0.65's v2 socket API (`system.tree`, `workspace.create`, +//! `surface.create`, `surface.split`, `surface.close`, `tab.action`). +//! +//! cmux shows what Sonny is doing without any of this: the program status +//! Sonny writes to its terminal (OSC 7501) is the workspace's status row, +//! and its notifications (OSC 777) ring the pane they came from. +use crate::mux::{Backend, Block, Place, Places}; +use serde_json::{Value, json}; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::OnceLock, +}; + +const MAC_APP: &str = "/Applications/cmux.app/Contents/Resources/bin/cmux"; + +/// A cmux terminal's own place, which a call naming another must not be +/// read as; the socket's path and the terminal's capability, which let a +/// process cmux started connect, are kept. +const PLACE_VARS: [&str; 4] = [ + "CMUX_WORKSPACE_ID", + "CMUX_SURFACE_ID", + "CMUX_TAB_ID", + "CMUX_PANEL_ID", +]; + +/// The cmux CLI: the one the terminal was given (`CMUX_BUNDLED_CLI_PATH`), +/// else `cmux` on the PATH, else the Mac app's. +pub fn find() -> Option { + if let Some(cmux) = std::env::var_os("CMUX_BUNDLED_CLI_PATH").filter(|c| !c.is_empty()) { + return Some(PathBuf::from(cmux)); + } + let on_path = std::env::var_os("PATH").and_then(|path| { + std::env::split_paths(&path) + .map(|dir| dir.join("cmux")) + .find(|cmux| cmux.is_file()) + }); + on_path.or_else(|| Path::new(MAC_APP).is_file().then(|| PathBuf::from(MAC_APP))) +} + +/// The cmux surface (terminal tab) Sonny runs in. Its ID stays with it +/// when it is moved to another pane or workspace. +pub fn surface() -> Option { + std::env::var("CMUX_SURFACE_ID") + .ok() + .filter(|s| !s.is_empty()) +} + +/// A v2 socket method's result. +fn rpc(bin: &Path, method: &str, params: &Value) -> Result { + let mut command = Command::new(bin); + for name in PLACE_VARS { + command.env_remove(name); + } + let out = command + .args(["rpc", method, ¶ms.to_string()]) + .stdin(Stdio::null()) + .output() + .map_err(|e| format!("cmux: {e}"))?; + if !out.status.success() { + // cmux says why, among others that its socket would not take this + // process: Settings > Automation in cmux decides which may connect. + return Err(format!( + "cmux {method}: {}", + String::from_utf8_lossy(&out.stderr).trim() + )); + } + if out.stdout.trim_ascii().is_empty() { + return Ok(Value::Null); + } + serde_json::from_slice(&out.stdout).map_err(|e| format!("cmux {method}: {e}")) +} + +/// Every workspace of every cmux window: its ID, title and panes. +fn workspaces(bin: &Path) -> Result, String> { + let tree = rpc(bin, "system.tree", &json!({"all_windows": true}))?; + Ok(workspaces_of(&tree).collect()) +} + +fn workspaces_of(tree: &Value) -> impl Iterator + '_ { + tree["windows"] + .as_array() + .into_iter() + .flatten() + .flat_map(|w| w["workspaces"].as_array().cloned().unwrap_or_default()) +} + +/// A workspace's surfaces, pane by pane: (pane, surface, title). +fn surfaces(workspace: &Value) -> impl Iterator { + workspace["panes"] + .as_array() + .into_iter() + .flatten() + .flat_map(|pane| { + let id = pane["id"].as_str().unwrap_or(""); + pane["surfaces"] + .as_array() + .into_iter() + .flatten() + .filter_map(move |s| { + Some((id, s["id"].as_str()?, s["title"].as_str().unwrap_or(""))) + }) + }) +} + +/// Whether the project's lead is in a tab of the cmux this shell reaches. +pub fn has_lead(bin: &Path, project: &str) -> bool { + let lead = format!("{project}.lead"); + workspaces(bin).is_ok_and(|all| { + all.iter() + .any(|w| surfaces(w).any(|(_, _, title)| title == lead)) + }) +} + +/// The tab Sonny runs in, whose resume command cmux keeps. +struct Resume { + bin: PathBuf, + surface: String, +} + +static RESUME: OnceLock = OnceLock::new(); + +/// Sonny runs on `bot` in this tab, when it is a cmux tab: cmux keeps +/// `resume` (`sonny` first) as the tab's resume command, which it runs +/// when it restores the tab after a relaunch, as it does for the agents +/// it supports, once the person allows commands that start with `sonny`. +/// In the background, so cmux never slows Sonny down. +pub fn announce(bot: &str, resume: Vec) { + let (Some(surface), Some(bin)) = (surface(), find()) else { + return; + }; + let me: &'static Resume = RESUME.get_or_init(|| Resume { bin, surface }); + let on_path = resume.first().is_some_and(|first| { + std::env::var_os("PATH") + .is_some_and(|path| std::env::split_paths(&path).any(|dir| dir.join(first).is_file())) + }); + if !on_path { + return; + } + let bot = bot.to_owned(); + let cwd = std::env::current_dir().unwrap_or_default(); + std::thread::spawn(move || me.set(&bot, &cwd, &resume)); +} + +/// Sonny quits on its own: the tab is the person's shell again, and cmux +/// restores it as one. A Sonny that goes with cmux keeps its resume +/// command, since it never gets here. +pub fn release() { + if let Some(me) = RESUME.get() { + me.clear(); + } +} + +impl Resume { + fn cli(&self) -> Command { + let mut command = Command::new(&self.bin); + for name in PLACE_VARS { + command.env_remove(name); + } + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + command + } + + /// Whether cmux took the resume command; it shows the tab as + /// `Sonny: NAME`. + fn set(&self, bot: &str, cwd: &Path, resume: &[String]) -> bool { + let short = bot.split_once('.').map_or(bot, |(_, name)| name); + self.cli() + .args(["surface", "resume", "set", "--surface", &self.surface]) + .args(["--name", &format!("Sonny: {short}")]) + .args(["--kind", "sonny", "--source", "sonny", "--cwd"]) + .arg(cwd) + .arg("--") + .args(resume) + .status() + .is_ok_and(|status| status.success()) + } + + fn clear(&self) -> bool { + self.cli() + .args(["surface", "resume", "clear", "--surface", &self.surface]) + .status() + .is_ok_and(|status| status.success()) + } +} + +pub struct Cmux { + bin: PathBuf, + workspace: String, + /// The workspace's title, as the person sees it. + name: String, + /// The project's lead, whose pane takes its tasks' tabs. + lead: String, + /// Whether the workspace is the one Sonny runs in. + here: bool, + /// The surface Sonny runs in. + me: Option, + /// Whether the workspace was made just now. + made: bool, +} + +impl Cmux { + fn call(&self, method: &str, params: Value) -> Result { + rpc(&self.bin, method, ¶ms) + } + + /// The project's workspace. From the tab Sonny runs in (`terminal`), + /// the workspace that tab is in now, so the lead stays where the + /// person opened it and tasks open beside it. Otherwise, as from an + /// agent's shell: the one `kept` names while it is open, else the + /// workspace the project's lead is in, else the one titled with the + /// project's name, made when there is none. + pub fn open( + bin: PathBuf, + label: &str, + terminal: Option<&str>, + kept: Option<&str>, + ) -> Result { + let mut cmux = Self { + bin, + workspace: String::new(), + name: label.to_owned(), + lead: format!("{label}.lead"), + here: false, + me: None, + made: false, + }; + let all = workspaces(&cmux.bin)?; + let id = |w: &Value| w["id"].as_str().map(str::to_owned); + let current = terminal.and_then(|me| { + all.iter() + .find(|w| surfaces(w).any(|(_, surface, _)| surface == me)) + }); + let kept = kept.and_then(|kept| all.iter().find(|w| w["id"] == kept)); + let leads = || { + all.iter() + .find(|w| surfaces(w).any(|(_, _, title)| title == cmux.lead)) + }; + let found = || all.iter().find(|w| w["title"] == label); + if current.is_some() { + cmux.here = true; + cmux.me = terminal.map(str::to_owned); + } + match current.or(kept).or_else(leads).or_else(found) { + Some(workspace) => { + cmux.workspace = id(workspace).ok_or("cmux system.tree: no workspace id")?; + cmux.name = workspace["title"].as_str().unwrap_or(label).to_owned(); + } + None => { + let made = + cmux.call("workspace.create", json!({"title": label, "focus": false}))?; + cmux.workspace = made["workspace_id"] + .as_str() + .ok_or("cmux workspace.create: no workspace_id")? + .to_owned(); + cmux.made = true; + } + } + Ok(cmux) + } + + /// This workspace, as `system.tree` has it. + fn tree(&self) -> Option { + let tree = self + .call( + "system.tree", + json!({"all_windows": true, "workspace_id": self.workspace}), + ) + .ok()?; + workspaces_of(&tree).find(|w| w["id"] == self.workspace.as_str()) + } + + /// The pane a task's tab goes in: the lead's, else this tab's, else the + /// workspace's first. + fn home_pane(&self, blocks: &[Block]) -> Option { + let mine = |b: &&Block| self.me.as_ref() == Some(&b.id); + (blocks.iter().find(|b| b.label == self.lead)) + .or_else(|| blocks.iter().find(mine)) + .or(blocks.first()) + .map(|b| b.window.clone()) + } + + /// The line typed into a new tab's shell: `command` in its place + /// (`exec`), so the tab goes with it, unless it is to be read once it + /// exits. + fn input(command: &[String], keep: bool) -> String { + let mut line: Vec = command.iter().map(|w| crate::herdr::quote(w)).collect(); + if !keep { + line.insert(0, "exec".into()); + } + format!("{}\r", line.join(" ")) + } +} + +impl Backend for Cmux { + fn home(&self) -> String { + format!("cmux workspace {}", self.name) + } + + fn here(&self) -> bool { + self.here + } + + fn made(&self) -> bool { + self.made + } + + /// Each tab, pane by pane: its title is its label, and its pane stands + /// for its window. + fn blocks(&self) -> Vec { + let Some(workspace) = self.tree() else { + return Vec::new(); + }; + surfaces(&workspace) + .map(|(pane, surface, title)| Block { + id: surface.to_owned(), + label: title.to_owned(), + window: pane.to_owned(), + }) + .collect() + } + + fn close_block(&self, block: &str) { + let _ = self.call( + "surface.close", + json!({"workspace_id": self.workspace, "surface_id": block, "force": true}), + ); + } + + /// A tab's title, which stays over the one its program sets. + fn rename_block(&self, block: &str, label: &str) { + let _ = self.call( + "tab.action", + json!({"action": "rename", "workspace_id": self.workspace, + "surface_id": block, "title": label}), + ); + } + + /// A pane has no name of its own: its tabs' titles are the bots' + /// labels, and what each is doing is the workspace's status row. + fn rename_window(&self, _: &str, _: &str) {} + + fn window( + &self, + label: &str, + _: &str, + cwd: &Path, + command: &[String], + keep: bool, + ) -> Result { + let mut params = json!({ + "workspace_id": self.workspace, + "initial_input": Self::input(command, keep), + "focus": false, + }); + if let Some(pane) = self.home_pane(&self.blocks()) { + params["pane_id"] = pane.into(); + } + if keep { + params["working_directory"] = cwd.to_string_lossy().into(); + } + let made = self.call("surface.create", params)?; + let surface = made["surface_id"] + .as_str() + .ok_or("cmux surface.create: no surface_id")? + .to_owned(); + self.rename_block(&surface, label); + Ok(surface) + } + + fn split( + &self, + anchor: &str, + side: &str, + small: bool, + label: &str, + cwd: &Path, + command: &[String], + keep: bool, + ) -> Result { + // The divider's place from the left or top: a small split keeps + // most of the room for the tab it is beside. + let (direction, divider) = match side { + "right" => ("right", 0.65), + "left" => ("left", 0.35), + "below" => ("down", 0.65), + "above" => ("up", 0.35), + _ => return Err(format!("no side {side}: right, left, above or below")), + }; + let mut params = json!({ + "workspace_id": self.workspace, + "surface_id": anchor, + "direction": direction, + "initial_input": Self::input(command, keep), + "focus": false, + }); + if small { + params["initial_divider_position"] = divider.into(); + } + if keep { + params["working_directory"] = cwd.to_string_lossy().into(); + } + let made = self.call("surface.split", params)?; + let surface = made["surface_id"] + .as_str() + .ok_or("cmux surface.split: no surface_id")? + .to_owned(); + self.rename_block(&surface, label); + Ok(surface) + } + + fn own_block(&self) -> Option { + self.me.clone() + } + + fn place(&self) -> Place { + Place::Cmux { + workspace: self.workspace.clone(), + } + } + + /// Where each tab sits, by label: its number in its pane, and when the + /// workspace is split, which side of the first other pane its pane is. + fn places(&self) -> Places { + let mut places = HashMap::new(); + let Some(workspace) = self.tree() else { + return places; + }; + let panes: Vec<&Value> = workspace["panes"] + .as_array() + .into_iter() + .flatten() + .collect(); + let frames: HashMap = match panes.len() { + 0 | 1 => HashMap::new(), + _ => self + .call("pane.list", json!({"workspace_id": self.workspace})) + .ok() + .and_then(|listed| listed["panes"].as_array().cloned()) + .into_iter() + .flatten() + .filter_map(|p| Some((p["id"].as_str()?.to_owned(), p["pixel_frame"].clone()))) + .collect(), + }; + let label = |pane: &Value| { + let selected = pane["selected_surface_id"].clone(); + let surfaces = pane["surfaces"].as_array()?; + let shown = (surfaces.iter()) + .find(|s| s["id"] == selected) + .or(surfaces.first())?; + shown["title"].as_str().map(str::to_owned) + }; + for pane in &panes { + let id = pane["id"].as_str().unwrap_or(""); + let side = panes + .iter() + .find(|other| other["id"] != pane["id"]) + .and_then(|other| { + let side = side_of(frames.get(id)?, frames.get(other["id"].as_str()?)?); + Some((side.to_owned(), label(other)?)) + }); + for (n, surface) in pane["surfaces"] + .as_array() + .into_iter() + .flatten() + .enumerate() + { + if let Some(title) = surface["title"].as_str() { + places.insert(title.to_owned(), (n + 1, side.clone())); + } + } + } + places + } +} + +/// Which side of `other` a pane at `frame` is, by their centres, in pixels. +fn side_of(frame: &Value, other: &Value) -> &'static str { + let centre = |r: &Value, at: &str, size: &str| { + r[at].as_f64().unwrap_or(0.0) + r[size].as_f64().unwrap_or(0.0) / 2.0 + }; + let dx = centre(frame, "x", "width") - centre(other, "x", "width"); + let dy = centre(frame, "y", "height") - centre(other, "y", "height"); + match (dx.abs() >= dy.abs(), dx >= 0.0, dy >= 0.0) { + (true, true, _) => "right of", + (true, false, _) => "left of", + (false, _, true) => "below", + (false, _, false) => "above", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mux::Layout; + + /// A stand-in cmux over a JSON file of workspaces, panes and tabs, that + /// logs each call it gets and answers as `cmux rpc` does. + const FAKE: &str = r#"#!/usr/bin/env python3 +import json, os, sys +here = os.path.dirname(os.path.abspath(sys.argv[0])) +state = json.load(open(os.path.join(here, "state.json"))) +args = sys.argv[1:] +open(os.path.join(here, "calls"), "a").write(json.dumps(args) + "\n") +def save(): + json.dump(state, open(os.path.join(here, "state.json"), "w")) +if args[0] != "rpc": + save() + sys.exit(0) +method, params = args[1], json.loads(args[2]) +def new_id(kind): + state["n"] += 1 + return "%s-%d" % (kind, state["n"]) +def find(surface): + for w in state["workspaces"]: + for p in w["panes"]: + for s in p["surfaces"]: + if s["id"] == surface: + return w, p, s +def tab(title="zsh"): + return {"id": new_id("sf"), "title": title} +out = {} +if method == "system.tree": + ws = [w for w in state["workspaces"] if params.get("workspace_id") in (None, w["id"])] + out = {"windows": [{"id": "win-1", "workspaces": ws}]} +elif method == "workspace.create": + s = tab() + w = {"id": new_id("ws"), "title": params["title"], + "panes": [{"id": new_id("pn"), "surfaces": [s], "selected_surface_id": s["id"]}]} + state["workspaces"].append(w) + out = {"workspace_id": w["id"], "surface_id": s["id"]} +elif method == "surface.create": + w = [w for w in state["workspaces"] if w["id"] == params["workspace_id"]][0] + p = [p for p in w["panes"] if p["id"] == params.get("pane_id")][0] + s = tab() + p["surfaces"].append(s) + out = {"workspace_id": w["id"], "pane_id": p["id"], "surface_id": s["id"]} +elif method == "surface.split": + w, p, _ = find(params["surface_id"]) + s = tab() + w["panes"].insert(w["panes"].index(p) + 1, + {"id": new_id("pn"), "surfaces": [s], "selected_surface_id": s["id"]}) + out = {"workspace_id": w["id"], "surface_id": s["id"]} +elif method == "surface.close": + w, p, s = find(params["surface_id"]) + p["surfaces"].remove(s) + if not p["surfaces"]: + w["panes"].remove(p) +elif method == "tab.action": + find(params["surface_id"])[2]["title"] = params["title"] +elif method == "pane.list": + w = [w for w in state["workspaces"] if w["id"] == params["workspace_id"]][0] + out = {"panes": [{"id": p["id"], "pixel_frame": p.get("frame")} for p in w["panes"]]} +save() +print(json.dumps(out)) +"#; + + fn fake(name: &str, state: Value) -> (PathBuf, PathBuf) { + let dir = std::env::temp_dir().join(format!("sonny-cmux-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let bin = dir.join("cmux"); + crate::testing::script(&bin, FAKE); + std::fs::write(dir.join("state.json"), state.to_string()).unwrap(); + (bin, dir) + } + + fn calls(dir: &Path) -> Vec> { + std::fs::read_to_string(dir.join("calls")) + .unwrap_or_default() + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect() + } + + /// The last call of `method`, its params. + fn last(dir: &Path, method: &str) -> Value { + let call = calls(dir) + .into_iter() + .rfind(|c| c[0] == "rpc" && c[1] == method) + .unwrap(); + serde_json::from_str(&call[2]).unwrap() + } + + /// Each pane's tab titles, workspace by workspace. + fn titles(dir: &Path) -> Vec>> { + let state: Value = + serde_json::from_slice(&std::fs::read(dir.join("state.json")).unwrap()).unwrap(); + let list = |v: &Value, key: &str| v[key].as_array().cloned().unwrap_or_default(); + list(&state, "workspaces") + .iter() + .map(|w| { + list(w, "panes") + .iter() + .map(|p| { + (list(p, "surfaces").iter()) + .map(|s| s["title"].as_str().unwrap().to_owned()) + .collect() + }) + .collect() + }) + .collect() + } + + fn pane(id: &str, tabs: &[(&str, &str)]) -> Value { + let surfaces: Vec = (tabs.iter()) + .map(|(id, title)| json!({"id": id, "title": title})) + .collect(); + json!({"id": id, "surfaces": surfaces, "selected_surface_id": tabs[0].0}) + } + + #[test] + fn an_agent_finds_its_leads_workspace_and_its_commands_close_with_it() { + let (bin, dir) = fake( + "home", + json!({ + "n": 10, + "workspaces": [ + {"id": "ws-1", "title": "lydakis", + "panes": [pane("pn-1", &[("sf-1", "graft.lead"), ("sf-2", "graft.fix")])]}, + {"id": "ws-2", "title": "graft", "panes": [pane("pn-2", &[("sf-3", "zsh")])]}, + ], + }), + ); + let cmux = Cmux::open(bin, "graft", None, None).unwrap(); + assert_eq!( + (cmux.workspace.as_str(), cmux.home().as_str()), + ("ws-1", "cmux workspace lydakis") + ); + let mut layout = Layout::new(Box::new(cmux)); + layout + .split( + "graft.fix", + "tests", + Path::new("/work"), + &["cargo".into(), "test it's".into()], + Some("below"), + ) + .unwrap(); + assert_eq!( + last(&dir, "surface.split"), + json!({"workspace_id": "ws-1", "surface_id": "sf-2", "direction": "down", + "initial_input": "cargo 'test it'\\''s'\r", "focus": false, + "initial_divider_position": 0.65, "working_directory": "/work"}) + ); + // A task's own Sonny runs in its tab's place, in the lead's pane. + let sonny = ["/bin/sonny".into(), "--bot".into(), "graft.docs".into()]; + assert!(layout.show("graft.docs", "docs", &sonny).unwrap()); + let made = last(&dir, "surface.create"); + assert_eq!( + (&made["pane_id"], &made["initial_input"]), + (&json!("pn-1"), &json!("exec /bin/sonny --bot graft.docs\r")) + ); + assert_eq!(made.get("working_directory"), None); + assert_eq!( + titles(&dir)[0], + [ + vec!["graft.lead", "graft.fix", "graft.docs"], + vec!["graft.fix/tests"] + ] + ); + assert!( + calls(&dir) + .iter() + .all(|c| !c.iter().any(|a| a.contains("ws-2"))) + ); + layout.close("graft.fix"); + assert_eq!(titles(&dir)[0], [vec!["graft.lead", "graft.docs"]]); + assert_eq!(last(&dir, "surface.close")["force"], true); + } + + #[test] + fn sonny_finds_its_tab_after_it_moved_and_names_it() { + let (bin, dir) = fake( + "moved", + json!({ + "n": 10, + "workspaces": [ + {"id": "ws-1", "title": "notes", "panes": [pane("pn-1", &[("sf-1", "zsh")])]}, + {"id": "ws-2", "title": "shop", "panes": [pane("pn-2", &[("sf-7", "zsh")])]}, + ], + }), + ); + let cmux = Cmux::open(bin, "shop", Some("sf-7"), None).unwrap(); + assert!(cmux.here()); + assert_eq!(cmux.workspace, "ws-2"); + let mut layout = Layout::new(Box::new(cmux)); + layout.adopt_own("shop.lead"); + let sleep = ["sleep".to_owned(), "60".into()]; + assert!(layout.show("shop.fix", "fix", &sleep).unwrap()); + assert!(!layout.show("shop.fix", "fix", &sleep).unwrap()); + assert_eq!( + titles(&dir), + [vec![vec!["zsh"]], vec![vec!["shop.lead", "shop.fix"]]] + ); + } + + #[test] + fn a_project_without_a_workspace_gets_one_holding_only_its_lead() { + let (bin, dir) = fake("new", json!({"n": 0, "workspaces": []})); + let cmux = Cmux::open(bin, "shop", None, None).unwrap(); + assert_eq!(cmux.home(), "cmux workspace shop"); + assert!(!cmux.here()); + assert_eq!( + last(&dir, "workspace.create"), + json!({"title": "shop", "focus": false}) + ); + let mut layout = Layout::new(Box::new(cmux)); + let sonny = ["sonny".to_owned()]; + assert!(layout.show("shop.lead", "lead", &sonny).unwrap()); + assert_eq!(titles(&dir), [vec![vec!["shop.lead"]]]); + } + + #[test] + fn an_agent_lays_work_out_in_the_workspace_its_lead_was_last_in() { + let state = json!({ + "n": 10, + "workspaces": [ + {"id": "ws-1", "title": "shop", "panes": [pane("pn-1", &[("sf-1", "shop.lead")])]}, + {"id": "ws-2", "title": "notes", "panes": [pane("pn-2", &[("sf-2", "zsh")])]}, + ], + }); + let (bin, _) = fake("kept", state); + let cmux = Cmux::open(bin.clone(), "shop", None, Some("ws-2")).unwrap(); + assert_eq!( + cmux.place(), + Place::Cmux { + workspace: "ws-2".into() + } + ); + // A kept workspace closed since: the lead's, as before. + let cmux = Cmux::open(bin.clone(), "shop", None, Some("ws-9")).unwrap(); + assert_eq!(cmux.workspace, "ws-1"); + assert!(has_lead(&bin, "shop")); + assert!(!has_lead(&bin, "notes")); + } + + #[test] + fn places_count_tabs_in_their_pane_and_say_which_side_a_split_is() { + let mut lead = pane("pn-1", &[("sf-1", "shop.lead"), ("sf-2", "shop.fix")]); + lead["frame"] = json!({"x": 0, "y": 0, "width": 800, "height": 900}); + let mut right = pane("pn-2", &[("sf-3", "shop.lead/server")]); + right["frame"] = json!({"x": 800, "y": 0, "width": 400, "height": 900}); + let (bin, _) = fake( + "places", + json!({"n": 10, "workspaces": [{"id": "ws-1", "title": "shop", "panes": [lead, right]}]}), + ); + let places = Cmux::open(bin, "shop", None, None).unwrap().places(); + let side = |s: &str, of: &str| Some((s.to_owned(), of.to_owned())); + assert_eq!( + places["shop.lead"], + (1, side("left of", "shop.lead/server")) + ); + assert_eq!(places["shop.fix"], (2, side("left of", "shop.lead/server"))); + assert_eq!( + places["shop.lead/server"], + (1, side("right of", "shop.lead")) + ); + } + + #[test] + fn cmux_keeps_how_to_reopen_sonny_until_it_quits() { + let (bin, dir) = fake("resume", json!({"n": 0, "workspaces": []})); + let me = Resume { + bin, + surface: "sf-4".into(), + }; + let resume = ["sonny".to_owned(), "--bot".into(), "shop.fix-cart".into()]; + assert!(me.set("shop.fix-cart", Path::new("/work"), &resume)); + assert!(me.clear()); + let calls = calls(&dir); + assert_eq!( + calls[0], + [ + "surface", + "resume", + "set", + "--surface", + "sf-4", + "--name", + "Sonny: fix-cart", + "--kind", + "sonny", + "--source", + "sonny", + "--cwd", + "/work", + "--", + "sonny", + "--bot", + "shop.fix-cart" + ] + ); + assert_eq!( + calls[1], + ["surface", "resume", "clear", "--surface", "sf-4"] + ); + } + + #[test] + fn sides_follow_the_panes_centres() { + let r = |x: u16, y: u16, w: u16, h: u16| json!({"x": x, "y": y, "width": w, "height": h}); + assert_eq!( + side_of(&r(600, 0, 600, 800), &r(0, 0, 600, 800)), + "right of" + ); + assert_eq!(side_of(&r(0, 500, 1200, 300), &r(0, 0, 1200, 500)), "below"); + assert_eq!(side_of(&r(0, 0, 1200, 500), &r(0, 500, 1200, 300)), "above"); + } +} diff --git a/sonny/src/herdr.rs b/sonny/src/herdr.rs index 48ce95f80..044baf1b3 100644 --- a/sonny/src/herdr.rs +++ b/sonny/src/herdr.rs @@ -284,7 +284,7 @@ pub struct Herdr { /// A word as a POSIX shell reads it back, for `pane run`, which types its /// command into the pane's shell. -fn quote(word: &str) -> String { +pub(crate) fn quote(word: &str) -> String { let plain = !word.is_empty() && word .bytes() diff --git a/sonny/src/main.rs b/sonny/src/main.rs index 0e4d2b80e..da244e090 100644 --- a/sonny/src/main.rs +++ b/sonny/src/main.rs @@ -15,6 +15,7 @@ mod background; mod banner; mod cache; mod calls; +mod cmux; mod declared; mod diff; mod embed; @@ -751,6 +752,7 @@ async fn run() -> Result<(), String> { // A side chat borrows its agent's pane, and Herdr keeps the agent. if options.side.is_none() { herdr::announce(&name, reopen()); + cmux::announce(&name, reopen()); } // Everything after now arrives live; history is read from the bot's // items instead of replayed events, so the replay is skipped. The bot @@ -1050,8 +1052,8 @@ fn unique(prefix: &str) -> String { } /// Program status (OSC 7501), so a terminal or multiplexer that reads it -/// (Ghostty, Rex) can mark this pane working, blocked or done; and the -/// same to Herdr, which takes it from its own CLI instead. +/// (Ghostty, Rex, cmux) can mark this pane working, blocked or done; and +/// the same to Herdr, which takes it from its own CLI instead. fn status_report(state: &str, bot: &str) { use base64::Engine; // A side chat borrows its agent's pane, which keeps saying what the @@ -1060,6 +1062,9 @@ fn status_report(state: &str, bot: &str) { return; } herdr::report(state); + if state == "clear" { + cmux::release(); + } let mut body = format!("state={state}"); if state == "blocked" { body.push_str(":kind=permission"); diff --git a/sonny/src/mux.rs b/sonny/src/mux.rs index 2aaff8001..f3066db26 100644 --- a/sonny/src/mux.rs +++ b/sonny/src/mux.rs @@ -1,7 +1,7 @@ //! Where a project's work is laid out: a terminal multiplexer the person -//! watches. Each project has a home there (a Rex session), with a window -//! per task and a block labelled with each bot's name, so it can be found -//! again by that name; a command a bot asked for is labelled `BOT/NAME` and +//! watches (Rex, Herdr or cmux). Each project has a home there (a Rex +//! session, a Herdr or cmux workspace), with a window per task and a block +//! labelled with each bot's name, so it can be found again by that name; a command a bot asked for is labelled `BOT/NAME` and //! closes with its owner. `Layout` keeps what Sonny does there; a `Backend` //! says how, in its multiplexer's CLI. use std::{ @@ -86,6 +86,9 @@ pub enum Place { Herdr { workspace: String, }, + Cmux { + workspace: String, + }, Rex { session: String, host: Option, @@ -106,6 +109,9 @@ impl Place { Self::Herdr { workspace } => { serde_json::json!({"multiplexer": "herdr", "workspace": workspace}) } + Self::Cmux { workspace } => { + serde_json::json!({"multiplexer": "cmux", "workspace": workspace}) + } Self::Rex { session, host } => { serde_json::json!({"multiplexer": "rex", "session": session, "host": host}) } @@ -127,6 +133,9 @@ impl Place { "herdr" => Some(Self::Herdr { workspace: text("workspace")?, }), + "cmux" => Some(Self::Cmux { + workspace: text("workspace")?, + }), "rex" => Some(Self::Rex { session: text("session")?, host: text("host"), @@ -333,14 +342,19 @@ impl Layout { enum Kind { Rex(PathBuf), Herdr(PathBuf), + Cmux(PathBuf), } /// The multiplexer this terminal is in: Herdr when Sonny runs in a Herdr -/// pane, Rex when in a Rex block or `host` names a Rex server. +/// pane (even one in a cmux tab, as the innermost), cmux when in a cmux +/// tab, Rex when in a Rex block or `host` names a Rex server. fn current(host: Option<&str>) -> Option { if host.is_none() && crate::herdr::pane().is_some() { return crate::herdr::find().map(Kind::Herdr); } + if host.is_none() && crate::cmux::surface().is_some() { + return crate::cmux::find().map(Kind::Cmux); + } let rex = std::env::var_os("REX_SESSION").is_some() || host.is_some(); rex.then(crate::rex::find).flatten().map(Kind::Rex) } @@ -355,6 +369,10 @@ pub fn here(socket: &Path, project: &str, host: Option<&str>) -> Option let pane = crate::herdr::pane(); Box::new(crate::herdr::Herdr::open(bin, project, pane.as_deref(), None).ok()?) } + Kind::Cmux(bin) => { + let surface = crate::cmux::surface(); + Box::new(crate::cmux::Cmux::open(bin, project, surface.as_deref(), None).ok()?) + } }; if let Some(file) = Place::file(socket, project) { backend.place().keep(&file); @@ -365,68 +383,78 @@ pub fn here(socket: &Path, project: &str, host: Option<&str>) -> Option /// The project's home, for an agent's call, from the lookout answering it, /// whose multiplexer variables would name the terminal Sonny started it /// in: where the lead's Sonny last laid the project out, in that -/// multiplexer; without one, in Herdr when the project's lead is in a -/// Herdr pane, else in the multiplexer project `like`'s home is in, else -/// in Rex (or Herdr, without Rex), made when needed. +/// multiplexer; without one, in Herdr or cmux when the project's lead is in +/// a Herdr pane or a cmux tab, else in the multiplexer project `like`'s +/// home is in, else in Rex (or Herdr, or cmux, without Rex), made when +/// needed. pub fn for_agent(socket: &Path, project: &str, like: Option<&str>) -> Result { let herdr = crate::herdr::find(); + let cmux = crate::cmux::find(); let rex = crate::rex::find(); let recall = |project| Place::file(socket, project).and_then(|file| Place::recall(&file)); let kept = recall(project); let like = like.and_then(recall); - let backend: Box = match (kept, herdr, rex) { - (Some(Place::Herdr { workspace }), Some(herdr), _) => Box::new(crate::herdr::Herdr::open( - herdr, - project, - None, - Some(&workspace), - )?), - (Some(Place::Rex { session, host }), _, Some(rex)) => Box::new(crate::rex::Rex::open( - rex, - host.as_deref(), - project, - false, - Some(&session), - )?), - (_, Some(herdr), _) - if crate::herdr::has_lead(&herdr, project) - || matches!(like, Some(Place::Herdr { .. })) => - { - Box::new(crate::herdr::Herdr::open(herdr, project, None, None)?) - } - (_, _, Some(rex)) if let Some(Place::Rex { host, .. }) = &like => Box::new( - crate::rex::Rex::open(rex, host.as_deref(), project, false, None)?, - ), - (_, _, Some(rex)) => Box::new(crate::rex::Rex::open(rex, None, project, false, None)?), - (_, Some(herdr), None) => Box::new(crate::herdr::Herdr::open(herdr, project, None, None)?), - (_, None, None) => return Err("neither rex nor herdr was found".into()), - }; + let backend: Box = + match (kept, herdr, cmux, rex) { + (Some(Place::Herdr { workspace }), Some(herdr), _, _) => Box::new( + crate::herdr::Herdr::open(herdr, project, None, Some(&workspace))?, + ), + (Some(Place::Cmux { workspace }), _, Some(cmux), _) => Box::new( + crate::cmux::Cmux::open(cmux, project, None, Some(&workspace))?, + ), + (Some(Place::Rex { session, host }), _, _, Some(rex)) => Box::new( + crate::rex::Rex::open(rex, host.as_deref(), project, false, Some(&session))?, + ), + (_, Some(herdr), _, _) + if crate::herdr::has_lead(&herdr, project) + || matches!(like, Some(Place::Herdr { .. })) => + { + Box::new(crate::herdr::Herdr::open(herdr, project, None, None)?) + } + (_, _, Some(cmux), _) + if crate::cmux::has_lead(&cmux, project) + || matches!(like, Some(Place::Cmux { .. })) => + { + Box::new(crate::cmux::Cmux::open(cmux, project, None, None)?) + } + (_, _, _, Some(rex)) if let Some(Place::Rex { host, .. }) = &like => Box::new( + crate::rex::Rex::open(rex, host.as_deref(), project, false, None)?, + ), + (_, _, _, Some(rex)) => { + Box::new(crate::rex::Rex::open(rex, None, project, false, None)?) + } + (_, Some(herdr), _, None) => { + Box::new(crate::herdr::Herdr::open(herdr, project, None, None)?) + } + (_, None, Some(cmux), None) => { + Box::new(crate::cmux::Cmux::open(cmux, project, None, None)?) + } + (_, None, None, None) => return Err("none of rex, herdr or cmux was found".into()), + }; Ok(Layout::new(backend)) } /// The skills that let any agent run long work where the person can see -/// it, for the multiplexer this terminal is in (the other's taken out, so +/// it, for the multiplexer this terminal is in (the others' taken out, so /// agents read one), and show the person pictures. Each names this /// machine's multiplexer CLI. pub fn install_skill(host: Option<&str>) { crate::skills::install("pictures", include_str!("../skills/pictures/SKILL.md")); let quoted = |path: &Path| format!("\"{}\"", path.to_string_lossy()); - let rex = |bin: PathBuf| { + let skill = + |name, text: &str, bin: &str, path: PathBuf| (name, text.replace(bin, "ed(&path))); + let rex = |bin| { let text = include_str!("../skills/rex-windows/SKILL.md"); - ( - "rex-windows", - "herdr-panes", - text.replace("REX_BIN", "ed(&bin)), - ) + skill("rex-windows", text, "REX_BIN", bin) }; - let (name, other, text) = match current(host) { + let (name, text) = match current(host) { Some(Kind::Herdr(bin)) => { let text = include_str!("../skills/herdr-panes/SKILL.md"); - ( - "herdr-panes", - "rex-windows", - text.replace("HERDR_BIN", "ed(&bin)), - ) + skill("herdr-panes", text, "HERDR_BIN", bin) + } + Some(Kind::Cmux(bin)) => { + let text = include_str!("../skills/cmux-tabs/SKILL.md"); + skill("cmux-tabs", text, "CMUX_BIN", bin) } Some(Kind::Rex(bin)) => rex(bin), // Out of sight, Rex is the default. @@ -436,7 +464,11 @@ pub fn install_skill(host: Option<&str>) { }, }; crate::skills::install(name, &text); - crate::skills::remove(other); + for other in ["rex-windows", "herdr-panes", "cmux-tabs"] { + if other != name { + crate::skills::remove(other); + } + } } #[cfg(test)] @@ -544,6 +576,11 @@ mod tests { }; herdr.keep(&file); assert_eq!(Place::recall(&file), Some(herdr)); + let cmux = Place::Cmux { + workspace: "8F2C".into(), + }; + cmux.keep(&file); + assert_eq!(Place::recall(&file), Some(cmux)); let rex = Place::Rex { session: "s7".into(), host: Some("mini".into()), diff --git a/sonny/src/project.rs b/sonny/src/project.rs index 511250180..272841f16 100644 --- a/sonny/src/project.rs +++ b/sonny/src/project.rs @@ -559,8 +559,8 @@ pub async fn add(caller: &str, arguments: &Value) -> Result { made } -/// A lead made for another agent opens in a home of its own, a Herdr -/// workspace or a Rex session beside the caller's, as the person would +/// A lead made for another agent opens in a home of its own, a Herdr or +/// cmux workspace or a Rex session beside the caller's, as the person would /// otherwise ask; `opened_in` says where, or `not_opened` why not. async fn open_new_lead(client: &Client, socket: &Path, caller: &str, made: &mut Value) { let Some(project) = made["project"].as_str() else { diff --git a/src/tools.rs b/src/tools.rs index ad8c4ded5..55cee0858 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -1784,6 +1784,10 @@ fn sh( "HERDR_WORKSPACE_ID", "HERDR_TAB_ID", "HERDR_PANE_ID", + "CMUX_WORKSPACE_ID", + "CMUX_SURFACE_ID", + "CMUX_TAB_ID", + "CMUX_PANEL_ID", ] { process.env_remove(name); } diff --git a/tests/test_approvals.py b/tests/test_approvals.py index 9f5d797fd..478c066ac 100644 --- a/tests/test_approvals.py +++ b/tests/test_approvals.py @@ -846,16 +846,19 @@ def test_the_approvers_keys_never_reach_a_tool(self): def test_the_daemons_own_terminal_never_reaches_a_tool(self): env = {**clean_env(), 'REX_SESSION': 'session:daemons', 'REX_BLOCK': 'block:daemons', 'TMUX': '/tmp/tmux-0/default,1,0', 'TMUX_PANE': '%0', 'HERDR_ENV': '1', - 'HERDR_WORKSPACE_ID': 'w1', 'HERDR_TAB_ID': 'w1:t1', 'HERDR_PANE_ID': 'w1:p1'} + 'HERDR_WORKSPACE_ID': 'w1', 'HERDR_TAB_ID': 'w1:t1', 'HERDR_PANE_ID': 'w1:p1', + 'CMUX_WORKSPACE_ID': 'ws-1', 'CMUX_SURFACE_ID': 'sf-1', 'CMUX_TAB_ID': 'ws-1', + 'CMUX_PANEL_ID': 'sf-1'} client = Client(self.binary, self.path / 'state.sqlite', self.url, 'shell', env=env) self.addCleanup(client.close) client.request('create', bot='Bob', workspace=str(self.path)) turn = client.request('submit', bot='Bob', request_id='a', prompt='shell:printf "${REX_SESSION-unset}${REX_BLOCK-unset}${TMUX-unset}${TMUX_PANE-unset}' - '${HERDR_ENV-unset}${HERDR_WORKSPACE_ID-unset}${HERDR_TAB_ID-unset}${HERDR_PANE_ID-unset}" > seen' + '${HERDR_ENV-unset}${HERDR_WORKSPACE_ID-unset}${HERDR_TAB_ID-unset}${HERDR_PANE_ID-unset}' + '${CMUX_WORKSPACE_ID-unset}${CMUX_SURFACE_ID-unset}${CMUX_TAB_ID-unset}${CMUX_PANEL_ID-unset}" > seen' )['result']['turn'] self.assertEqual(client.finished(turn)['data']['status'], 'completed') - self.assertEqual((self.path / 'seen').read_text(), 'unset' * 8) + self.assertEqual((self.path / 'seen').read_text(), 'unset' * 12) @unittest.skipUnless(os.environ.get('AGENT_TEST_RUNTIME') == '1', 'set AGENT_TEST_RUNTIME=1 after a Rust release build') From 7e880810382585c0b2c5b4f795aaae3ae4614797 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 23:39:27 +0000 Subject: [PATCH 02/17] Leave a Sonny in a Herdr pane to Herdr when cmux hosts Herdr Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFMvxuKK3qzEb4j48d --- sonny/src/cmux.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sonny/src/cmux.rs b/sonny/src/cmux.rs index ca45e6b7c..36d6a4b34 100644 --- a/sonny/src/cmux.rs +++ b/sonny/src/cmux.rs @@ -132,8 +132,12 @@ static RESUME: OnceLock = OnceLock::new(); /// `resume` (`sonny` first) as the tab's resume command, which it runs /// when it restores the tab after a relaunch, as it does for the agents /// it supports, once the person allows commands that start with `sonny`. -/// In the background, so cmux never slows Sonny down. +/// In the background, so cmux never slows Sonny down. A Sonny in a Herdr +/// pane inside a cmux tab is Herdr's to restore, not the tab's. pub fn announce(bot: &str, resume: Vec) { + if crate::herdr::pane().is_some() { + return; + } let (Some(surface), Some(bin)) = (surface(), find()) else { return; }; From 83dad61d126a7eefe2a47479b035e0d6e07f918e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 01:18:20 +0000 Subject: [PATCH 03/17] Sonny: `sonny acp`, Sonny as a T3 Code provider T3 Code runs `sonny acp` as an ACP provider, a process per thread. A thread is its folder's lead, set up as `sonny` sets it up (rules, skills, tools, approval mode and sandbox, the lookout). Turns and calls become the thread's updates; a call waiting for the person is asked with session/request_permission; turns the lead takes on its own (the lookout waking it) show between prompts, as ACP v2 work the agent started. Each task the lead starts opens as a T3 thread of its own via T3's t3_thread_launch tool, and that thread binds to the task. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFMvxuKK3qzEb4j48d --- sonny/README.md | 26 ++ sonny/src/acp.rs | 1118 +++++++++++++++++++++++++++++++++++++++++++++ sonny/src/main.rs | 8 +- sonny/src/t3.rs | 308 +++++++++++++ tests/test_acp.py | 229 ++++++++++ 5 files changed, 1688 insertions(+), 1 deletion(-) create mode 100644 sonny/src/acp.rs create mode 100644 sonny/src/t3.rs create mode 100644 tests/test_acp.py diff --git a/sonny/README.md b/sonny/README.md index f9256da8f..f3835c46a 100644 --- a/sonny/README.md +++ b/sonny/README.md @@ -175,6 +175,32 @@ a line to wait for. The skill tells agents to close what they opened once read; whatever a task left open closes with its window. +#### T3 Code + +`sonny acp` is Sonny for [T3 Code](https://github.com/pingdotgg/t3code): it +speaks the Agent Client Protocol on stdio, so T3 Code runs it as a provider, +one process for each thread. In T3 Code's Settings, add an "ACP Registry" +provider, configure it by hand as a local command, and give it the +executable `sonny` (or the full path `command -v sonny` prints) with the one +argument `acp`. A thread opened in a folder is that folder's lead, the +one `sonny` there opens, set up the same way: its rules, skills and memory, +Sonny's tools, its approval mode and sandbox, and the lookout. A call that +waits for you is asked in the thread (Allow or Deny); one the judge passes +runs without asking. The turns the lead takes on its own, as when the lookout +wakes it because a task finished, show in the thread between yours, as work +the agent started (the protocol's version 2 draft, which T3 Code speaks). +Each task the lead starts opens as a T3 Code thread of its own, in the task's +worktree (in the project's folder when T3 Code does not take the worktree as +one of the project's), with the task's brief as its first message; that +thread is the task's, so it shows the task's work and steers it. Sonny opens +it with the `t3_thread_launch` tool T3 Code gives each thread it runs, by the +credential T3 Code scopes to that thread, which no program Sonny starts +inherits. Sonny takes the person's words out of the instructions T3 Code +wraps a prompt in, which name tools Sonny's agents do not have, so T3 Code's +Plan mode does not reach them; plan with the lead instead. What thread is +which bot is kept in `~/.agent/sonny/t3`, so a thread T3 Code resumes is the +same bot. + Enter sends (and steers a running turn), Esc interrupts, `y`/`n` answer a pending approval, Ctrl-C clears the composer or quits. Shift-Enter or Alt-Enter (or Ctrl-J) starts a new line. The composer takes a shell's editing diff --git a/sonny/src/acp.rs b/sonny/src/acp.rs new file mode 100644 index 000000000..7f5054a34 --- /dev/null +++ b/sonny/src/acp.rs @@ -0,0 +1,1118 @@ +//! `sonny acp`: Sonny as an agent of the Agent Client Protocol, for T3 +//! Code, which runs it as a provider ("Local command"), a process for each +//! thread. A thread in a folder is that folder's lead, as `sonny` there +//! finds or makes it, set up the same way: its rules and skills, its tools, +//! its approval mode, and the lookout. Each task the lead starts opens as a +//! thread of its own, through T3 Code's tools, and that thread is the +//! task's. +//! +//! It speaks the protocol's version 2 draft as T3 Code does: the turns a +//! bot takes on its own, as when the lookout wakes a lead because a task +//! finished, reach the thread between the person's prompts, as work the +//! agent started. +use crate::{approvals, declared, lookout, plan, project, reactions, t3}; +use agent_client::{Client, Events}; +use serde_json::{Value, json}; +use std::collections::{HashMap, HashSet}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::{Arc, Mutex}; +use tokio::sync::{Mutex as AsyncMutex, OnceCell, oneshot}; + +pub const VERB: &str = "acp"; + +/// The protocol version Sonny speaks: the draft that lets an agent start +/// work of its own between prompts. +const VERSION: i64 = 2; + +/// The most of a call's output a thread is shown. +const OUTPUT_BYTES: usize = 8 * 1024; + +/// JSON-RPC's codes for what Sonny refuses. +const INVALID_PARAMS: i64 = -32602; +const NOT_FOUND: i64 = -32601; +const FAILED: i64 = -32603; + +/// `sonny acp [--store PATH] [--socket PATH]`. +pub fn run(args: &[String]) -> Result<(), String> { + let (mut store, mut socket) = (None, None); + let mut args = args.iter(); + while let Some(arg) = args.next() { + let mut value = || { + args.next() + .cloned() + .ok_or("usage: sonny acp [--store PATH] [--socket PATH]") + }; + match arg.as_str() { + "--store" => store = Some(PathBuf::from(value()?)), + "--socket" => socket = Some(PathBuf::from(value()?)), + _ => return Err("usage: sonny acp [--store PATH] [--socket PATH]".into()), + } + } + let (store, socket) = crate::daemon_paths(store, socket)?; + let t3 = t3::T3::from_env(); + for name in t3::T3::VARS { + // SAFETY: nothing else runs yet: no thread is started before this. + unsafe { std::env::remove_var(name) }; + } + let options = crate::Options { + store, + socket, + bot: None, + new: false, + model: None, + workspace: None, + layout: false, + rex_host: None, + sound: false, + side: None, + }; + let agent = Arc::new(Agent { + out: Arc::new(Out::default()), + options, + t3, + sessions: Mutex::new(HashMap::new()), + ready: OnceCell::new(), + version: AtomicI64::new(VERSION), + }); + tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .map_err(|e| e.to_string())? + .block_on(agent.serve()) +} + +/// The client's side of the line: what Sonny writes, and the answers to +/// what it asked. +#[derive(Default)] +struct Out { + stdout: Mutex<()>, + next: AtomicI64, + waiting: Mutex>>, +} + +impl Out { + fn send(&self, message: Value) { + let _held = self.stdout.lock(); + let mut stdout = std::io::stdout().lock(); + let _ = writeln!(stdout, "{message}"); + let _ = stdout.flush(); + } + + fn answer(&self, id: &Value, result: Value) { + self.send(json!({"jsonrpc": "2.0", "id": id, "result": result})); + } + + fn fail(&self, id: &Value, code: i64, message: &str) { + self.send(json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})); + } + + fn update(&self, session: &str, update: Value) { + self.send(json!({"jsonrpc": "2.0", "method": "session/update", + "params": {"sessionId": session, "update": update}})); + } + + /// Ask the client: its answer, or `None` when it gave an error or none. + async fn ask(&self, method: &str, params: Value) -> Option { + let id = self.next.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = oneshot::channel(); + if let Ok(mut waiting) = self.waiting.lock() { + waiting.insert(id, tx); + } + self.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})); + let answer = rx.await.ok()?; + answer.get("result").cloned() + } + + fn answered(&self, message: Value) { + let Some(id) = message["id"].as_i64() else { + return; + }; + let waiting = self.waiting.lock().ok().and_then(|mut w| w.remove(&id)); + if let Some(tx) = waiting { + let _ = tx.send(message); + } + } +} + +struct Agent { + out: Arc, + options: crate::Options, + t3: Option, + sessions: Mutex>>, + /// The daemon, reached once, with Sonny's skills installed first. + ready: OnceCell>, + /// The version the client asked for, when older. + version: AtomicI64, +} + +impl Agent { + async fn serve(self: Arc) -> Result<(), String> { + let (tx, mut lines) = tokio::sync::mpsc::unbounded_channel::(); + // Stdin is read on a thread of its own: tokio's stdin is too. + std::thread::spawn(move || { + for line in std::io::stdin().lines() { + let Ok(line) = line else { break }; + if tx.send(line).is_err() { + break; + } + } + }); + while let Some(line) = lines.recv().await { + let Ok(message) = serde_json::from_str::(&line) else { + if !line.trim().is_empty() { + self.out.send(json!({"jsonrpc": "2.0", "id": null, + "error": {"code": -32700, "message": "not JSON"}})); + } + continue; + }; + match (message.get("method"), message.get("id")) { + (Some(_), Some(_)) => { + let agent = self.clone(); + tokio::spawn(async move { agent.request(message).await }); + } + (Some(_), None) => self.notified(&message).await, + (None, Some(_)) => self.out.answered(message), + (None, None) => {} + } + } + Ok(()) + } + + async fn request(self: Arc, message: Value) { + let id = &message["id"]; + let params = &message["params"]; + let answered = match message["method"].as_str().unwrap_or("") { + "initialize" => Ok(self.initialize(params)), + "authenticate" | "auth/login" => Ok(json!({})), + "session/new" => self.open(params, None).await, + "session/resume" | "session/load" => match params["sessionId"].as_str() { + Some(session) => self.open(params, Some(session)).await, + None => Err((INVALID_PARAMS, "sessionId is missing".into())), + }, + "session/prompt" => match self.session(params) { + Some(session) => session.prompt(&self, id, params).await, + None => Err((INVALID_PARAMS, "no such session".into())), + }, + "session/close" => { + if let Some(session) = params["sessionId"].as_str() + && let Ok(mut sessions) = self.sessions.lock() + { + sessions.remove(session); + } + Ok(json!({})) + } + other => Err((NOT_FOUND, format!("{other} is not a method Sonny has"))), + }; + match answered { + Ok(Value::Null) => {} + Ok(result) => self.out.answer(id, result), + Err((code, message)) => self.out.fail(id, code, &message), + } + } + + async fn notified(&self, message: &Value) { + if message["method"] == "session/cancel" + && let Some(session) = self.session(&message["params"]) + { + session.cancel().await; + } + } + + fn session(&self, params: &Value) -> Option> { + let id = params["sessionId"].as_str()?; + self.sessions.lock().ok()?.get(id).cloned() + } + + fn initialize(&self, params: &Value) -> Value { + let asked = params["protocolVersion"].as_i64().unwrap_or(VERSION); + let version = asked.clamp(1, VERSION); + self.version.store(version, Ordering::Relaxed); + json!({ + "protocolVersion": version, + "agentCapabilities": { + "loadSession": false, + "promptCapabilities": {"image": true, "audio": false, "embeddedContext": true}, + "mcpCapabilities": {"http": false, "sse": false, "acp": false}, + "sessionCapabilities": {"resume": {}, "close": {}} + }, + "authMethods": [], + "agentInfo": {"name": "sonny", "title": "Sonny", "version": env!("CARGO_PKG_VERSION")} + }) + } + + /// Whether the client follows work the agent starts on its own. + fn v2(&self) -> bool { + self.version.load(Ordering::Relaxed) >= 2 + } + + /// The daemon, started as `sonny` starts it, with Sonny's skills + /// installed before any lead is made, so its instructions list them. + async fn ready(&self) -> Result<(), String> { + self.ready + .get_or_init(|| async { + crate::skills::install_own(); + crate::trigger::at_start(); + crate::connect(&self.options).await.map(drop) + }) + .await + .clone() + } + + /// A thread starts (`session/new`) or comes back (`session/resume`). + /// A new one is not a bot yet: its first prompt says whether it is a + /// task's thread or its folder's lead. + async fn open(self: &Arc, params: &Value, resumed: Option<&str>) -> Reply { + let cwd = params["cwd"] + .as_str() + .map(PathBuf::from) + .filter(|cwd| cwd.is_absolute()) + .ok_or((INVALID_PARAMS, "cwd must be an absolute path".to_owned()))?; + self.ready().await.map_err(|e| (FAILED, e))?; + let id = match resumed { + Some(id) => id.to_owned(), + None => crate::unique("sonny"), + }; + let session = Arc::new(Session { + id: id.clone(), + cwd, + state: AsyncMutex::new(State::default()), + }); + // Open before it follows its bot: a closed thread stops following. + if let Ok(mut sessions) = self.sessions.lock() { + sessions.insert(id.clone(), session.clone()); + } + if let Some(bot) = resumed.and_then(t3::recall) + && let Err(error) = session.bind(self, Some(bot), false).await + { + if let Ok(mut sessions) = self.sessions.lock() { + sessions.remove(&id); + } + return Err((FAILED, error)); + } + Ok(match resumed { + Some(_) => json!({}), + None => json!({"sessionId": id}), + }) + } +} + +type Reply = Result; + +/// A thread: the bot it is, once its first prompt says, and the person's +/// prompt waiting for its turn's end. +struct Session { + id: String, + cwd: PathBuf, + state: AsyncMutex, +} + +#[derive(Default)] +struct State { + bound: Option, + /// The person's prompt, answered when its turn ends; its turn once the + /// daemon named it. + prompt: Option<(Value, Option)>, + /// The turn running, and whether this thread said it started. + running: Option, + /// Words since the last call, by kind, and the part of the turn they + /// are in: a new message after each call. + part: u32, + streamed: bool, + calls: HashMap, + /// Calls put to the person, by call and request. + asked: HashSet, +} + +struct Bound { + bot: String, + client: Arc, + /// The lead's project, for a lead's thread. + project: Option, + workspace: String, + /// The folder a moved lead comes back to with its next turn. + moving: Option, +} + +impl Session { + /// Make this thread `bot`'s, or its folder's lead's, and follow it. + /// `claimed`: a task that waited for this thread, whose first turn + /// runs already. + async fn bind( + self: &Arc, + agent: &Arc, + bot: Option, + claimed: bool, + ) -> Result<(), String> { + let (client, mut events) = Client::connect(&agent.options.socket) + .await + .map_err(|e| e.to_string())?; + let (bot, project, moving) = match bot { + Some(bot) => { + let project = bot.strip_suffix(".lead").map(str::to_owned); + (bot, project, None) + } + None => { + let lead = project::open(&client, &self.cwd, None).await?; + let moving = lead.moving.as_ref().map(|dir| dir.display().to_string()); + setup_lead(agent, &self.id, &client, &lead, moving.as_deref()).await?; + (lead.bot, Some(lead.project), moving) + } + }; + client + .request("follow", json!({"bot": bot, "after": 0})) + .await + .map_err(|e| e.to_string())?; + crate::caught_up(&mut events).await; + let record = client + .request("inspect", json!({"bot": bot})) + .await + .map_err(|e| e.to_string())?; + let workspace = record["workspace"].as_str().unwrap_or_default().to_owned(); + t3::remember(&self.id, &bot); + { + let mut state = self.state.lock().await; + state.running = record["running_turn"].as_i64(); + state.bound = Some(Bound { + bot, + client, + project, + workspace, + moving, + }); + // The thread T3 Code opened for a task stands for its first + // turn, which runs already. + if claimed && let Some(turn) = state.running { + state.prompt = state.prompt.take().map(|(id, _)| (id, Some(turn))); + } + } + let (session, agent) = (self.clone(), agent.clone()); + tokio::spawn(async move { session.follow(&agent, events).await }); + Ok(()) + } + + async fn prompt(self: &Arc, agent: &Arc, id: &Value, params: &Value) -> Reply { + let (text, images) = words(¶ms["prompt"]); + let text = t3::request(&text).to_owned(); + let bound = self.state.lock().await.bound.is_some(); + if !bound { + // The first prompt of a thread T3 Code opened for a task is + // that task's brief. + let claimed = t3::claim(&text); + let task = claimed.is_some(); + // A task's thread stands for the turn the task runs already. + if task { + self.state.lock().await.prompt = Some((id.clone(), None)); + } + if let Err(error) = self.bind(agent, claimed, task).await { + self.state.lock().await.prompt = None; + return Err((FAILED, error)); + } + if task { + let mut state = self.state.lock().await; + // Its turn ended before the thread came: nothing to wait for. + if state.running.is_none() { + state.prompt = None; + drop(state); + self.say_last(agent).await; + return Ok(json!({"stopReason": "end_turn"})); + } + if agent.v2() { + agent.out.update( + &self.id, + json!({"sessionUpdate": "state_update", "state": "running"}), + ); + } + return Ok(Value::Null); + } + } + let mut state = self.state.lock().await; + let Some(bound) = &state.bound else { + return Err((FAILED, "the thread has no bot".into())); + }; + let steer = state.running.is_some(); + let mut asked = json!({"bot": bound.bot, "request_id": crate::unique("acp"), "prompt": text, + "delivery": if steer { json!("steer") } else { Value::Null }, + "images": if images.is_empty() { Value::Null } else { json!(images) }}); + if !steer && let Some(dir) = &bound.moving { + asked["workspace"] = json!(dir); + } + let client = bound.client.clone(); + // A prompt that steers joins the turn an earlier one waits on, + // which is answered now: its words are in. + let running = state.running; + if let Some((earlier, _)) = state.prompt.replace((id.clone(), running)) { + agent + .out + .answer(&earlier, json!({"stopReason": "end_turn"})); + } + drop(state); + match client.request("submit", asked).await { + Ok(reply) => { + let mut state = self.state.lock().await; + if let Some(bound) = &mut state.bound + && !steer + { + bound.moving = None; + } + if let Some((_, turn @ None)) = &mut state.prompt { + *turn = reply["turn"].as_i64(); + } + Ok(Value::Null) + } + Err(error) => { + self.state.lock().await.prompt = None; + Err((FAILED, error.to_string())) + } + } + } + + async fn cancel(&self) { + let state = self.state.lock().await; + if let (Some(bound), Some(turn)) = (&state.bound, state.running) { + let _ = (bound.client) + .request("interrupt", json!({"bot": bound.bot, "turn": turn})) + .await; + } + } + + /// The bot's last reply, for a task's thread that came after its turn. + async fn say_last(&self, agent: &Agent) { + let state = self.state.lock().await; + let Some(bound) = &state.bound else { return }; + let Ok(page) = (bound.client) + .request("history_nodes", json!({"bot": bound.bot, "limit": 64})) + .await + else { + return; + }; + let nodes: Vec = page["nodes"] + .as_array() + .into_iter() + .flatten() + .filter_map(|node| node["node"].as_i64()) + .collect(); + // Newest first, as the nodes were asked for. + let reply = items(&bound.client, &bound.bot, &nodes) + .await + .into_iter() + .filter(|item| item["role"] == "assistant") + .map(|item| crate::text(&item)) + .find(|text| !text.is_empty()); + if let Some(reply) = reply { + agent.out.update(&self.id, json!({"sessionUpdate": "agent_message_chunk", + "messageId": format!("{}-last", bound.bot), "content": {"type": "text", "text": reply}})); + } + } + + /// The bot's events, as this thread's updates, until it is closed. + async fn follow(self: Arc, agent: &Arc, mut events: Events) { + while let Some(event) = events.recv().await { + let open = agent + .sessions + .lock() + .is_ok_and(|s| s.contains_key(&self.id)); + if !open { + break; + } + self.on(agent, &event).await; + } + } + + async fn on(&self, agent: &Arc, event: &Value) { + let mut state = self.state.lock().await; + let Some(bound) = &state.bound else { return }; + if event["bot"] != bound.bot.as_str() { + return; + } + let (client, bot) = (bound.client.clone(), bound.bot.clone()); + let turn = event["turn"].as_i64(); + let data = &event["data"]; + let out = &agent.out; + let id = self.id.as_str(); + match event["event"].as_str().unwrap_or("") { + "accepted" => { + // A steer joins the running turn, whose calls go on. + if state.running != turn { + state.part = 0; + state.streamed = false; + state.calls.clear(); + } + state.running = turn; + let ours = match &mut state.prompt { + Some((_, at @ None)) => { + *at = turn; + true + } + Some((_, at)) => *at == turn, + None => false, + }; + if agent.v2() { + out.update( + id, + json!({"sessionUpdate": "state_update", "state": "running"}), + ); + } + // A turn the bot took on its own, as one the lookout woke + // it for: what started it, as the thread's next message. + if !ours + && let Some(node) = data["node"].as_i64() + && let Some(item) = items(&client, &bot, &[node]).await.into_iter().next() + && !reactions::is_reaction(&crate::text(&item)) + { + out.update( + id, + json!({"sessionUpdate": "user_message", + "messageId": format!("{bot}-{node}"), + "content": [{"type": "text", "text": crate::text(&item)}]}), + ); + } + } + "text_delta" => { + state.streamed = true; + let text = event["text"].as_str().unwrap_or(""); + out.update(id, json!({"sessionUpdate": "agent_message_chunk", + "messageId": message_id(&bot, turn, state.part), "content": {"type": "text", "text": text}})); + } + "thinking_delta" => { + let text = event["text"].as_str().unwrap_or(""); + out.update( + id, + json!({"sessionUpdate": "agent_thought_chunk", + "messageId": format!("{}-thought", message_id(&bot, turn, state.part)), + "content": {"type": "text", "text": text}}), + ); + } + // Words that did not stream, as from a thread that came late. + "message" if !state.streamed => { + if let Some(node) = data["node"].as_i64() + && let Some(item) = items(&client, &bot, &[node]).await.into_iter().next() + && item["role"] == "assistant" + && !crate::text(&item).is_empty() + { + out.update( + id, + json!({"sessionUpdate": "agent_message_chunk", + "messageId": message_id(&bot, turn, state.part), + "content": {"type": "text", "text": crate::text(&item)}}), + ); + } + } + "tool_started" => { + let name = data["name"].as_str().unwrap_or("tool").to_owned(); + let call = data["call_id"].as_str().unwrap_or("").to_owned(); + let arguments: Value = data["arguments"] + .as_str() + .and_then(|a| serde_json::from_str(a).ok()) + .unwrap_or(Value::Null); + state.part += 1; + state.streamed = false; + if name == reactions::TOOL { + return; + } + if name == plan::TOOL { + out.update( + id, + json!({"sessionUpdate": "plan", "entries": plan_entries(&arguments)}), + ); + return; + } + out.update(id, tool_call(&call, &name, &arguments)); + state.calls.insert(call, (name, arguments)); + } + "tool_completed" => { + let call = data["call_id"].as_str().unwrap_or("").to_owned(); + state + .asked + .retain(|asked| !asked.starts_with(&format!("{call}#"))); + let Some((name, arguments)) = state.calls.remove(&call) else { + return; + }; + let (mut shown, mut failed) = (String::new(), data["failed"] == true); + if let Some(node) = data["node"].as_i64() + && let Some(item) = items(&client, &bot, &[node]) + .await + .into_iter() + .find(|item| item["call_id"] == call.as_str()) + { + (shown, failed) = crate::output(&item); + failed |= data["failed"] == true; + } + if data["denied"] == true { + failed = true; + } + let status = if failed { "failed" } else { "completed" }; + out.update( + id, + json!({"sessionUpdate": "tool_call_update", "toolCallId": call, + "status": status, "content": [text_content(&cut(&shown))]}), + ); + // A task the lead started opens as a thread of its own. + if name == crate::served::START + && !failed + && shown.starts_with("Started ") + && let Some(project) = state.bound.as_ref().and_then(|b| b.project.clone()) + && agent.t3.is_some() + { + let workspace = state + .bound + .as_ref() + .map(|b| b.workspace.clone()) + .unwrap_or_default(); + let (agent, session) = (agent.clone(), self.id.clone()); + tokio::spawn(async move { + open_task( + &agent, &session, &client, &project, &arguments, &workspace, &call, + ) + .await + }); + } + } + "approval_requested" | "approval_asked" => { + drop(state); + self.approvals(agent, &client, &bot, turn).await; + } + "turn_finished" => { + if state.running.is_some() && state.running != turn { + return; + } + state.running = None; + state.calls.clear(); + let reason = match data["status"].as_str().unwrap_or("") { + "interrupted" | "cancelled" => "cancelled", + _ => "end_turn", + }; + let failed = data["status"] == "failed"; + let mut ended = + json!({"sessionUpdate": "state_update", "state": "idle", "stopReason": reason}); + if failed { + let detail = data["error"].as_str().unwrap_or("the turn failed"); + ended["error"] = json!({"code": FAILED, "message": detail}); + } + if agent.v2() { + out.update(id, ended); + } + let ours = matches!(&state.prompt, Some((_, at)) if at.is_none() || *at == turn); + if ours && let Some((prompt, _)) = state.prompt.take() { + let mut result = json!({"stopReason": reason}); + if failed { + result["error"] = json!({"code": FAILED, + "message": data["error"].as_str().unwrap_or("the turn failed")}); + } + out.answer(&prompt, result); + } + } + "deleted" => { + if let Some((prompt, _)) = state.prompt.take() { + out.fail(&prompt, FAILED, &format!("{bot} was deleted")); + } + } + _ => {} + } + } + + /// The calls of `turn` waiting for the person, each put to them once: + /// the gate it waits on is the person's (`ask`), or the judge handed + /// it over (`auto`). + async fn approvals( + &self, + agent: &Arc, + client: &Arc, + bot: &str, + turn: Option, + ) { + let Ok(page) = client + .request("approvals", json!({"bot": bot, "limit": 64})) + .await + else { + return; + }; + for call in page["approvals"].as_array().into_iter().flatten() { + let person = !call["asked"].is_null() + || (call["gates"].as_array()) + .is_some_and(|gates| gates.iter().any(|g| g == "manual")); + if call["turn"].as_i64() != turn || !person { + continue; + } + let key = format!( + "{}#{}", + call["call_id"].as_str().unwrap_or(""), + call["request"] + ); + if !self.state.lock().await.asked.insert(key) { + continue; + } + let (agent, client, bot, call) = + (agent.clone(), client.clone(), bot.to_owned(), call.clone()); + let session = self.id.clone(); + tokio::spawn(async move { ask(&agent, &session, &client, &bot, &call).await }); + } + } +} + +/// Put one call to the person, and answer it with their choice. +async fn ask(agent: &Agent, session: &str, client: &Client, bot: &str, call: &Value) { + let id = call["call_id"].as_str().unwrap_or(""); + let name = call["name"].as_str().unwrap_or("tool"); + let mut update = tool_call(id, name, &call["arguments"]); + update["sessionUpdate"] = Value::Null; + if let Value::Object(fields) = &mut update { + fields.remove("sessionUpdate"); + } + let options = json!([ + {"optionId": "allow", "name": "Allow", "kind": "allow_once"}, + {"optionId": "deny", "name": "Deny", "kind": "reject_once"} + ]); + let title = update["title"].as_str().unwrap_or(name).to_owned(); + let answer = agent + .out + .ask( + "session/request_permission", + json!({"sessionId": session, "title": title, "toolCall": update, "options": options}), + ) + .await; + // No choice, as when the client gave up on it: the call waits for an + // answer from anywhere else. + let decision = match answer.as_ref().map(|a| &a["outcome"]) { + Some(outcome) if outcome["outcome"] == "selected" && outcome["optionId"] == "allow" => { + "allow" + } + Some(outcome) if outcome["outcome"] == "selected" => "deny", + _ => return, + }; + let _ = client + .request( + "answer", + json!({"bot": bot, "turn": call["turn"], "call_id": id, "request": call["request"], + "tag": crate::person_gate(call), "decision": decision, "by": "acp"}), + ) + .await; +} + +/// Set up the lead a thread is, as `sonny` sets up the lead it opens: the +/// tools Sonny gives it, its folder's rules and skills as they are today, +/// the approval mode a new lead starts in, the lookout watching its tasks, +/// and a judge for its calls. +async fn setup_lead( + agent: &Agent, + session: &str, + client: &Client, + lead: &project::Lead, + moving: Option<&str>, +) -> Result<(), String> { + let record = client + .request("inspect", json!({"bot": lead.bot})) + .await + .map_err(|e| e.to_string())?; + let key = project::lead(&agent.options.socket, &record); + let workspace = moving + .or(record["workspace"].as_str()) + .unwrap_or_default() + .to_owned(); + let mut asked = json!({"bot": lead.bot}); + if let Ok(project::Instructions::Stale(text)) = + project::review(Path::new(&workspace), &record, &key, lead.new) + { + asked["instructions"] = json!(text); + } + let declare = declared::declare(&record, true); + if let Some(declare) = &declare { + asked["declare"] = declare.clone(); + } + let mode = approvals::mode(&record); + let mut gating = lead.new.then_some(approvals::MODES[0].0); + if gating.is_none() + && let Some(mode) = mode.filter(|mode| *mode != "full") + && !approvals::sandboxed_as( + &record, + &approvals::sandbox(mode, true, Path::new(&workspace)), + ) + { + gating = Some(mode); + } + let gating = gating.or(declare.as_ref().and(mode).filter(|mode| *mode != "full")); + if let (Some(mode), Value::Object(fields)) = (gating, &mut asked) { + let tools: Vec = record["tools"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(); + let declared = declare.as_ref().unwrap_or(&record["declared"]); + fields.extend(approvals::gate( + mode, + &approvals::gateable(&tools, declared), + )); + fields.extend(approvals::sandbox(mode, true, Path::new(&workspace))); + } + // A lead at work keeps its setup until the next thread finds it idle. + if asked.as_object().is_some_and(|fields| fields.len() > 1) { + match client.request("configure", asked.clone()).await { + Ok(_) => { + if let Some(text) = asked["instructions"].as_str() { + project::wrote(&key, text); + } + } + Err(error) if error.code == "bot_busy" => {} + // As in Sonny's window: the lead works as it was, and the + // thread says why. + Err(error) => agent.out.update( + session, + json!({"sessionUpdate": "notice", + "severity": "warning", "title": format!("{} kept its setup", lead.bot), + "description": error.to_string()}), + ), + } + } + lookout::subscribe_lead(&agent.options.store, &lead.bot, &lead.project)?; + crate::start_lookout(&agent.options)?; + if gating.or(mode) == Some("auto") { + let model = format!( + "{}/{}", + record["provider"].as_str().unwrap_or(""), + record["model"].as_str().unwrap_or("") + ); + crate::serve_auto(client, &agent.options, &model).await?; + } + Ok(()) +} + +/// Open the task a lead's `start_task` call started as a thread of its +/// own, with the task's brief as its first message; the thread is the +/// task's when that message reaches it. The lead's call says where. +async fn open_task( + agent: &Agent, + session: &str, + client: &Client, + project: &str, + arguments: &Value, + lead_workspace: &str, + call: &str, +) { + let Some(t3) = &agent.t3 else { return }; + let Some(name) = arguments["name"].as_str() else { + return; + }; + let bot = format!("{project}.{name}"); + let opened = async { + let record = client + .request("inspect", json!({"bot": bot})) + .await + .map_err(|e| e.to_string())?; + let turns = client + .request("turns", json!({"bot": bot, "limit": 1})) + .await + .map_err(|e| e.to_string())?; + let first = &turns["turns"][0]["turn"]; + let prompts = client + .request("prompts", json!({"bot": bot, "turn": first})) + .await + .map_err(|e| e.to_string())?; + let brief = prompts["prompts"][0]["text"] + .as_str() + .ok_or("the task has no brief")? + .to_owned(); + t3::expect(&bot, &brief)?; + let workspace = record["workspace"].as_str().unwrap_or_default(); + let mut launch = json!({"title": name, "message": brief, "clientRequestId": bot}); + if workspace != lead_workspace { + launch["workspaceStrategy"] = + json!({"type": "existing_worktree", "worktreePath": workspace}); + } + match t3.call("t3_thread_launch", launch.clone()).await { + // A folder T3 Code does not take as one of the project's: the + // thread works in the project's, as the task's still says. + Err(_) if !launch["workspaceStrategy"].is_null() => { + if let Some(fields) = launch.as_object_mut() { + fields.remove("workspaceStrategy"); + } + t3.call("t3_thread_launch", launch).await + } + launched => launched, + } + } + .await; + match opened { + Ok(launched) => { + if let Some(link) = launched["link"].as_str() { + agent.out.update( + session, + json!({"sessionUpdate": "tool_call_update", "toolCallId": call, + "content": [text_content(&format!("Opened {bot}: {link}"))]}), + ); + } + } + Err(error) => { + t3::forget(&bot); + agent.out.update( + session, + json!({"sessionUpdate": "notice", "severity": "warning", + "title": format!("{bot} has no thread"), "description": error}), + ); + } + } +} + +/// A prompt's words and pictures: text as it is, a file or resource as its +/// path or its text, a picture as the daemon takes one. +fn words(prompt: &Value) -> (String, Vec) { + let mut text = String::new(); + let mut images = Vec::new(); + for block in prompt.as_array().into_iter().flatten() { + match block["type"].as_str().unwrap_or("") { + "text" => text.push_str(block["text"].as_str().unwrap_or("")), + "image" => images.push(json!({"media_type": block["mimeType"], "data": block["data"]})), + "resource_link" => { + let uri = block["uri"].as_str().unwrap_or(""); + text.push_str(uri.strip_prefix("file://").unwrap_or(uri)); + } + "resource" => { + let resource = &block["resource"]; + let uri = resource["uri"].as_str().unwrap_or(""); + let path = uri.strip_prefix("file://").unwrap_or(uri); + match resource["text"].as_str() { + Some(body) => text.push_str(&format!("\n{path}:\n```\n{body}\n```\n")), + None => text.push_str(path), + } + } + _ => {} + } + } + (text, images) +} + +/// The update that shows a call: what it does, in a few words. +fn tool_call(call: &str, name: &str, arguments: &Value) -> Value { + let path = arguments["path"].as_str(); + let (kind, title) = match name { + "shell" => ( + "execute", + arguments["command"] + .as_str() + .map(|c| c.lines().next().unwrap_or("").to_owned()) + .unwrap_or_else(|| "shell".into()), + ), + "read" => ("read", format!("Read {}", path.unwrap_or(""))), + "write" | "edit" | "apply_patch" => ("edit", format!("Edit {}", path.unwrap_or("files"))), + "web_search" => ("fetch", "Search the web".to_owned()), + "wait" => ("other", "Wait".to_owned()), + _ => ("other", name.to_owned()), + }; + let mut update = json!({"sessionUpdate": "tool_call", "toolCallId": call, "title": title.trim(), + "name": name, "kind": kind, "status": "in_progress", "rawInput": arguments}); + if let Some(path) = path { + update["locations"] = json!([{"path": path}]); + } + update +} + +/// A `plan` call's steps, as the protocol's plan. +fn plan_entries(arguments: &Value) -> Vec { + arguments["steps"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(|step| { + let (status, content) = match step.split_at_checked(4) { + Some(("[x] ", rest)) => ("completed", rest), + Some(("[>] ", rest)) => ("in_progress", rest), + Some(("[ ] ", rest)) => ("pending", rest), + _ => ("pending", step), + }; + json!({"content": content, "priority": "medium", "status": status}) + }) + .collect() +} + +fn text_content(text: &str) -> Value { + json!({"type": "content", "content": {"type": "text", "text": text}}) +} + +/// At most `OUTPUT_BYTES` of a call's output, on a character boundary. +fn cut(text: &str) -> String { + if text.len() <= OUTPUT_BYTES { + return text.to_owned(); + } + let mut end = OUTPUT_BYTES; + while !text.is_char_boundary(end) { + end -= 1; + } + format!("{}\n…", &text[..end]) +} + +fn message_id(bot: &str, turn: Option, part: u32) -> String { + format!("{bot}-{}-{part}", turn.unwrap_or(0)) +} + +async fn items(client: &Client, bot: &str, nodes: &[i64]) -> Vec { + match client + .request("history_items", json!({"bot": bot, "nodes": nodes})) + .await + { + Ok(read) => read["items"] + .as_array() + .into_iter() + .flatten() + .map(|entry| entry["item"].clone()) + .collect(), + Err(_) => Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_prompt_is_words_paths_and_pictures() { + let prompt = json!([ + {"type": "text", "text": "Look at "}, + {"type": "resource_link", "name": "a.rs", "uri": "file:///src/a.rs"}, + {"type": "resource", "resource": {"uri": "file:///src/b.rs", "text": "fn b() {}"}}, + {"type": "image", "mimeType": "image/png", "data": "AAAA"} + ]); + let (text, images) = words(&prompt); + assert_eq!(text, "Look at /src/a.rs\n/src/b.rs:\n```\nfn b() {}\n```\n"); + assert_eq!( + images, + vec![json!({"media_type": "image/png", "data": "AAAA"})] + ); + } + + #[test] + fn a_plan_is_the_protocols_plan() { + let entries = + plan_entries(&json!({"steps": ["[x] Read", "[>] Write", "[ ] Test", "Ship"]})); + let status: Vec<&str> = entries + .iter() + .map(|e| e["status"].as_str().unwrap()) + .collect(); + assert_eq!(status, ["completed", "in_progress", "pending", "pending"]); + assert_eq!(entries[1]["content"], "Write"); + assert_eq!(entries[3]["content"], "Ship"); + } + + #[test] + fn a_call_says_what_it_does() { + let shell = tool_call("c1", "shell", &json!({"command": "cargo test\necho done"})); + assert_eq!( + (shell["kind"].as_str(), shell["title"].as_str()), + (Some("execute"), Some("cargo test")) + ); + let edit = tool_call("c2", "edit", &json!({"path": "/src/a.rs"})); + assert_eq!(edit["locations"], json!([{"path": "/src/a.rs"}])); + assert_eq!(edit["title"], "Edit /src/a.rs"); + } + + #[test] + fn long_output_is_cut_on_a_character() { + let long = "é".repeat(OUTPUT_BYTES); + let cut = cut(&long); + assert!(cut.len() <= OUTPUT_BYTES + 4); + assert!(cut.ends_with('…')); + } +} diff --git a/sonny/src/main.rs b/sonny/src/main.rs index 2444fd6d1..96e871cd6 100644 --- a/sonny/src/main.rs +++ b/sonny/src/main.rs @@ -8,6 +8,7 @@ //! alternate screen, replies stream a line at a time, tool calls are one //! `• Ran …` line with a short `â””` look at the result, and a status row //! says how long the turn has run and how to stop it. +mod acp; mod approvals; mod asks; mod attach; @@ -50,6 +51,7 @@ mod shell; mod skills; mod sound; mod status; +mod t3; mod table; mod task; mod term; @@ -175,7 +177,9 @@ fn usage() -> String { it when its tasks need it and answers its agents' calls of Sonny's tools; Sonny\n\ starts one. sonny lookout --replay --judge SPEC[,SPEC]... [--limit N] runs its\n\ checks over what the store kept.\n\ - sonny agent ARGS... runs Sonny's own `agent` with ARGS, whatever `agent` is on PATH." + sonny agent ARGS... runs Sonny's own `agent` with ARGS, whatever `agent` is on PATH.\n\ + sonny acp [--store PATH] [--socket PATH] speaks the Agent Client Protocol on stdio\n\ + for T3 Code: a thread is its folder's lead, and each task opens as a thread of its own." .into() } @@ -663,6 +667,8 @@ fn main() -> std::process::ExitCode { } Some((lookout, rest)) if lookout == "lookout" => lookout::run(rest), Some((agent, rest)) if agent == "agent" => pass_to_agent(rest), + // T3 Code runs this as a provider, a process for each thread. + Some((verb, rest)) if verb == acp::VERB => acp::run(rest), _ => tokio::runtime::Builder::new_multi_thread() .worker_threads(2) .enable_all() diff --git a/sonny/src/t3.rs b/sonny/src/t3.rs new file mode 100644 index 000000000..8ad8befc8 --- /dev/null +++ b/sonny/src/t3.rs @@ -0,0 +1,308 @@ +//! T3 Code, as `sonny acp` meets it: the tools T3 Code gives the agents it +//! runs, over an MCP endpoint it names in their environment, with which a +//! lead's thread opens a thread for each task the lead starts; the tasks +//! waiting for their thread, and the bot each thread is. +use serde_json::{Value, json}; +use std::path::PathBuf; + +/// The MCP protocol version Sonny asks T3 Code's endpoint for, as T3 +/// Code's own terminal fallback does. +const MCP_VERSION: &str = "2025-06-18"; + +/// How long a task waits for its thread to claim it. +const PENDING_SECS: u64 = 10 * 60; + +/// T3 Code's tools for the thread this process serves: the endpoint and the +/// credential T3 Code scopes to that thread. +pub struct T3 { + endpoint: String, + authorization: String, +} + +impl T3 { + /// T3 Code's endpoint, when T3 Code started this process. + pub fn from_env() -> Option { + let var = |name| std::env::var(name).ok().filter(|v| !v.is_empty()); + Some(T3 { + endpoint: var("T3_ACP_MCP_ENDPOINT")?, + authorization: var("T3_ACP_MCP_AUTHORIZATION")?, + }) + } + + /// The variables `from_env` reads: the credential is this thread's, and + /// no program Sonny starts (the daemon, the lookout) takes it. + pub const VARS: [&str; 4] = [ + "T3_ACP_MCP_ENDPOINT", + "T3_ACP_MCP_AUTHORIZATION", + "T3_ACP_MCP_NODE", + "T3_ACP_MCP_ENTRYPOINT", + ]; + + /// Call one of T3 Code's tools: what it returned, or why not. + pub async fn call(&self, tool: &str, arguments: Value) -> Result { + let http = reqwest::Client::new(); + let mut session = Mcp { + http: &http, + t3: self, + id: None, + version: None, + }; + session + .request( + "initialize", + json!({"protocolVersion": MCP_VERSION, "capabilities": {}, + "clientInfo": {"name": "sonny", "version": env!("CARGO_PKG_VERSION")}}), + ) + .await?; + let called = async { + session + .post(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})) + .await?; + session + .request("tools/call", json!({"name": tool, "arguments": arguments})) + .await + } + .await; + session.end().await; + result(tool, called?) + } +} + +/// One MCP session on T3 Code's endpoint. +struct Mcp<'a> { + http: &'a reqwest::Client, + t3: &'a T3, + id: Option, + version: Option, +} + +impl Mcp<'_> { + fn headers(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + let mut request = request + .header("accept", "application/json, text/event-stream") + .header("authorization", &self.t3.authorization); + if let Some(id) = &self.id { + request = request.header("mcp-session-id", id); + } + if let Some(version) = &self.version { + request = request.header("mcp-protocol-version", version); + } + request + } + + /// Post one message: what came back, as JSON or as server-sent events. + async fn post(&mut self, message: &Value) -> Result, String> { + let response = self + .headers(self.http.post(&self.t3.endpoint)) + .header("content-type", "application/json") + .body(message.to_string()) + .send() + .await + .map_err(|e| format!("T3 Code's tools: {e}"))?; + if let Some(id) = response.headers().get("mcp-session-id") { + self.id = id.to_str().ok().map(str::to_owned); + } + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("T3 Code's tools: {e}"))?; + if !status.is_success() { + return Err(format!("T3 Code's tools answered HTTP {}", status.as_u16())); + } + Ok(payloads(&body)) + } + + async fn request(&mut self, method: &str, params: Value) -> Result { + let id = format!("sonny-{method}"); + let replies = self + .post(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})) + .await?; + let reply = replies + .into_iter() + .find(|reply| reply["id"] == id.as_str()) + .ok_or_else(|| format!("T3 Code's tools did not answer {method}"))?; + if !reply["error"].is_null() { + return Err(format!("T3 Code's {method}: {}", reply["error"])); + } + if let Some(version) = reply["result"]["protocolVersion"].as_str() { + self.version = Some(version.to_owned()); + } + Ok(reply["result"].clone()) + } + + async fn end(&self) { + if self.id.is_some() { + let _ = self + .headers(self.http.delete(&self.t3.endpoint)) + .send() + .await; + } + } +} + +/// The messages in a reply's body: one JSON value or a list of them, or +/// the `data` of each server-sent event. +fn payloads(body: &str) -> Vec { + let parsed = |text: &str| match serde_json::from_str::(text) { + Ok(Value::Array(all)) => all, + Ok(one) => vec![one], + Err(_) => Vec::new(), + }; + let whole = parsed(body.trim()); + if !whole.is_empty() { + return whole; + } + body.lines() + .filter_map(|line| line.strip_prefix("data:")) + .flat_map(|data| parsed(data.trim())) + .collect() +} + +/// A tool's result: its structured content, else its text as JSON, else +/// its text; or the error it reported. +fn result(tool: &str, called: Value) -> Result { + let text: String = called["content"] + .as_array() + .into_iter() + .flatten() + .filter_map(|part| part["text"].as_str()) + .collect(); + if called["isError"] == true { + return Err(format!("T3 Code's {tool}: {text}")); + } + if !called["structuredContent"].is_null() { + return Ok(called["structuredContent"].clone()); + } + Ok(serde_json::from_str(&text).unwrap_or(Value::String(text))) +} + +/// What the person wrote, out of a prompt T3 Code wrapped in its own +/// instructions: those name tools the bot does not have. +pub fn request(prompt: &str) -> &str { + const OPEN: &str = "\n"; + const CLOSE: &str = "\n"; + if !prompt.starts_with(" &inside[at + OPEN.len()..], + _ => prompt, + } +} + +/// Where Sonny keeps what threads are which bots, and the tasks waiting +/// for theirs. +fn dir(of: &str) -> Option { + let home = std::env::var_os("HOME")?; + Some(PathBuf::from(home).join(".agent/sonny/t3").join(of)) +} + +fn file_name(bot: &str) -> String { + bot.replace('/', "%2F") +} + +/// The task `bot` waits for the thread opened with `brief`. +pub fn expect(bot: &str, brief: &str) -> Result<(), String> { + let dir = dir("pending").ok_or("HOME is not set")?; + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let record = json!({"bot": bot, "brief": brief, "at": now}); + std::fs::write(dir.join(file_name(bot)), record.to_string()).map_err(|e| e.to_string()) +} + +/// No thread comes for `bot`. +pub fn forget(bot: &str) { + if let Some(dir) = dir("pending") { + let _ = std::fs::remove_file(dir.join(file_name(bot))); + } +} + +/// The task a thread's first prompt opens: the one waiting with that +/// brief, which no other thread then claims. Ones waiting too long go. +pub fn claim(prompt: &str) -> Option { + let dir = dir("pending")?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let mut found = None; + for entry in std::fs::read_dir(&dir).ok()?.flatten() { + let Ok(text) = std::fs::read_to_string(entry.path()) else { + continue; + }; + let Ok(record) = serde_json::from_str::(&text) else { + continue; + }; + if now.saturating_sub(record["at"].as_u64().unwrap_or(0)) > PENDING_SECS { + let _ = std::fs::remove_file(entry.path()); + continue; + } + if found.is_none() + && record["brief"].as_str().map(str::trim) == Some(prompt.trim()) + // Claimed by whoever removes it. + && std::fs::remove_file(entry.path()).is_ok() + { + found = record["bot"].as_str().map(str::to_owned); + } + } + found +} + +/// The bot the thread `session` is, for its next start. +pub fn remember(session: &str, bot: &str) { + if let Some(dir) = dir("sessions") { + let _ = std::fs::create_dir_all(&dir); + let _ = std::fs::write(dir.join(file_name(session)), bot); + } +} + +pub fn recall(session: &str) -> Option { + let text = std::fs::read_to_string(dir("sessions")?.join(file_name(session))).ok()?; + Some(text.trim().to_owned()).filter(|bot| !bot.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_persons_words_come_out_of_t3_codes_wrapping() { + let wrapped = "\n## T3 Code interaction mode: Default\n\nUse delegate_task.\n\n\n\nFix the cart.\nThen test it.\n"; + assert_eq!(request(wrapped), "Fix the cart.\nThen test it."); + let orchestration = "x\n\n\nHi\n"; + assert_eq!(request(orchestration), "Hi"); + // Words of the person's own that look like it stay. + assert_eq!( + request("\nHi\n"), + "\nHi\n" + ); + assert_eq!(request("Fix it."), "Fix it."); + } + + #[test] + fn a_reply_is_json_or_server_sent_events() { + assert_eq!(payloads(r#"{"id":1}"#), vec![json!({"id": 1})]); + assert_eq!( + payloads("event: message\ndata: {\"id\":1}\n\ndata: {\"id\":2}\n\n"), + vec![json!({"id": 1}), json!({"id": 2})] + ); + assert!(payloads("").is_empty()); + } + + #[test] + fn a_result_is_structured_or_text() { + let structured = json!({"content": [{"type": "text", "text": "x"}], "structuredContent": {"threadId": "t"}}); + assert_eq!(result("t", structured), Ok(json!({"threadId": "t"}))); + let text = json!({"content": [{"type": "text", "text": "{\"threadId\":\"t\"}"}]}); + assert_eq!(result("t", text), Ok(json!({"threadId": "t"}))); + let failed = json!({"content": [{"type": "text", "text": "no project"}], "isError": true}); + assert_eq!( + result("launch", failed), + Err("T3 Code's launch: no project".into()) + ); + } +} diff --git a/tests/test_acp.py b/tests/test_acp.py new file mode 100644 index 000000000..1bf20398d --- /dev/null +++ b/tests/test_acp.py @@ -0,0 +1,229 @@ +"""`sonny acp`, as T3 Code runs it: a thread is its folder's lead, its turns +and calls are the thread's updates, a call waiting for the person is asked +in the thread, and a task the lead starts opens as a thread of its own, +through T3 Code's tools, which that thread then follows.""" +import http.server +import json +import os +import queue +import subprocess +import threading +import time +import unittest + +from bench.socket_client import SocketClient +from bench.targets import clean_env +from tests.test_runtime import ModelFixture +from tests.test_served import declared + + +class T3Tools(http.server.BaseHTTPRequestHandler): + """T3 Code's MCP endpoint, as small as `t3_thread_launch` needs.""" + + def log_message(self, *_): + pass + + def reply(self, status, body=None, session=None): + data = json.dumps(body).encode() if body is not None else b'' + self.send_response(status) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', str(len(data))) + if session: + self.send_header('mcp-session-id', session) + self.end_headers() + self.wfile.write(data) + + def do_POST(self): + message = json.loads(self.rfile.read(int(self.headers['Content-Length']))) + self.server.headers.append(dict(self.headers)) + if message['method'] == 'initialize': + return self.reply(200, {'jsonrpc': '2.0', 'id': message['id'], + 'result': {'protocolVersion': '2025-06-18', 'capabilities': {'tools': {}}, + 'serverInfo': {'name': 't3'}}}, session='s1') + if 'id' not in message: + return self.reply(202) + self.server.calls.put(message['params']) + result = {'threadId': 'thread-2', 'link': 't3://thread/thread-2', 'status': 'started'} + self.reply(200, {'jsonrpc': '2.0', 'id': message['id'], + 'result': {'content': [{'type': 'text', 'text': json.dumps(result)}], + 'structuredContent': result}}) + + def do_DELETE(self): + self.reply(200) + + +class Thread: + """One `sonny acp` process, as T3 Code drives it over stdio.""" + + def __init__(self, test, env, cwd): + self.process = subprocess.Popen( + [str(test.sonny), 'acp', '--store', str(test.store), '--socket', str(test.daemon.socket_path)], + env=env, cwd=cwd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) + test.addCleanup(self.close) + self.messages = queue.Queue() + self.next = 0 + threading.Thread(target=self.read, daemon=True).start() + + def read(self): + for line in self.process.stdout: + self.messages.put(json.loads(line)) + + def close(self): + if self.process.poll() is None: + self.process.kill() + self.process.wait(timeout=5) + self.process.stdin.close() + self.process.stdout.close() + + def send(self, message): + self.process.stdin.write(json.dumps({'jsonrpc': '2.0', **message}) + '\n') + self.process.stdin.flush() + + def ask(self, method, params): + self.next += 1 + self.send({'id': self.next, 'method': method, 'params': params}) + return self.next + + def until(self, done, timeout=30): + """Every message up to and including the first `done` takes.""" + seen = [] + deadline = time.monotonic() + timeout + while True: + left = deadline - time.monotonic() + if left <= 0: + raise AssertionError(f'timed out after {seen}') + try: + message = self.messages.get(timeout=left) + except queue.Empty: + continue + seen.append(message) + if done(message): + return seen + + def answer_of(self, request): + seen = self.until(lambda m: m.get('id') == request and 'method' not in m) + return seen[-1], [m['params']['update'] for m in seen if m.get('method') == 'session/update'] + + +@unittest.skipUnless(os.environ.get('AGENT_TEST_RUNTIME') == '1', 'set AGENT_TEST_RUNTIME=1 after a Rust release build') +class AcpTests(ModelFixture): + def setUp(self): + super().setUp() + self.sonny = self.binary.parent / 'sonny' + self.store = self.path / 'state.sqlite' + self.daemon = SocketClient(self.binary, self.store, self.url, 'echo') + self.addCleanup(self.daemon.close) + self.t3 = http.server.ThreadingHTTPServer(('127.0.0.1', 0), T3Tools) + self.t3.calls, self.t3.headers = queue.Queue(), [] + threading.Thread(target=self.t3.serve_forever, daemon=True).start() + self.addCleanup(self.t3.server_close) + self.addCleanup(self.t3.shutdown) + self.env = {**clean_env(), 'HOME': str(self.path), 'AGENT_BIN': str(self.binary), + 'GIT_AUTHOR_NAME': 't', 'GIT_AUTHOR_EMAIL': 't@example.com', + 'GIT_COMMITTER_NAME': 't', 'GIT_COMMITTER_EMAIL': 't@example.com', + 'T3_ACP_MCP_ENDPOINT': f'http://127.0.0.1:{self.t3.server_port}/mcp', + 'T3_ACP_MCP_AUTHORIZATION': 'Bearer thread-1'} + # The lookout the threads find running, judging nothing. + lookout = subprocess.Popen([str(self.sonny), 'lookout', '--judge', 'off', '--store', str(self.store), + '--socket', str(self.daemon.socket_path)], + env=self.env, stdout=subprocess.PIPE, text=True) + self.addCleanup(lambda: (lookout.kill(), lookout.wait(timeout=5), lookout.stdout.close())) + self.assertEqual(json.loads(lookout.stdout.readline())['event'], 'watching') + + def repo(self): + repo = self.path / 'calc' + repo.mkdir() + for args in (['init', '-q', '-b', 'main'], ['commit', '-q', '--allow-empty', '-m', 'one']): + subprocess.run(['git', *args], cwd=repo, env=self.env, check=True, capture_output=True) + return repo + + def open(self, cwd): + thread = Thread(self, self.env, cwd) + initialized, _ = thread.answer_of(thread.ask('initialize', { + 'protocolVersion': 2, 'clientCapabilities': {}, 'clientInfo': {'name': 't3-code'}})) + self.assertEqual(initialized['result']['protocolVersion'], 2) + self.assertEqual(initialized['result']['agentInfo']['name'], 'sonny') + opened, _ = thread.answer_of(thread.ask('session/new', {'cwd': str(cwd), 'mcpServers': []})) + return thread, opened['result']['sessionId'] + + @staticmethod + def wrapped(text): + """A prompt as T3 Code sends it, in its own instructions.""" + return [{'type': 'text', 'text': '\nUse delegate_task.\n\n\n' + f'\n{text}\n'}] + + def test_a_thread_is_its_folders_lead_and_a_task_it_starts_opens_as_a_thread_of_its_own(self): + repo = self.repo() + self.daemon.request('create', bot='calc.lead', workspace=str(repo), model='openai/synthetic-model', + instructions='Lead.', declare=[declared('start_task', inherit=False)], + approve=['start_task'], approver='manual') + thread, session = self.open(repo) + self.model.call_script = [('start_task', {'name': 'fix', 'brief': 'Fix calc.py.'})] + prompt = thread.ask('session/prompt', {'sessionId': session, 'prompt': self.wrapped('script')}) + # The call waits for the person, who is asked in the thread. + [asked] = [m for m in thread.until(lambda m: m.get('method') == 'session/request_permission') + if m.get('method') == 'session/request_permission'] + self.assertEqual(asked['params']['toolCall']['name'], 'start_task') + self.assertEqual([o['kind'] for o in asked['params']['options']], ['allow_once', 'reject_once']) + thread.send({'id': asked['id'], 'result': {'outcome': {'outcome': 'selected', 'optionId': 'allow'}}}) + answered, updates = thread.answer_of(prompt) + self.assertEqual(answered['result'], {'stopReason': 'end_turn'}) + kinds = [u['sessionUpdate'] for u in updates] + self.assertEqual(kinds[-1], 'state_update') + self.assertEqual(updates[-1]['state'], 'idle') + [started] = [u for u in updates if u['sessionUpdate'] == 'tool_call'] + self.assertEqual((started['title'], started['kind']), ('start_task', 'other')) + [ended] = [u for u in updates if u['sessionUpdate'] == 'tool_call_update' and 'status' in u] + self.assertEqual(ended['status'], 'completed') + self.assertIn('Started calc.fix', ended['content'][0]['content']['text']) + self.assertIn('done', ''.join(u['content']['text'] for u in updates + if u['sessionUpdate'] == 'agent_message_chunk')) + # The thread was the folder's lead, which T3 Code's words never + # reached: Sonny took the person's out of them. + [turn] = self.daemon.request('turns', bot='calc.lead')['result']['turns'] + prompts = self.daemon.request('prompts', bot='calc.lead', turn=turn['turn'])['result']['prompts'] + self.assertEqual(prompts[0]['text'], 'script') + + # The task opens as a thread of its own, in its worktree, with its + # brief as the first message, through this thread's own tools. + launch = self.t3.calls.get(timeout=30) + self.assertEqual(launch['name'], 't3_thread_launch') + worktree = self.path / '.agent/worktrees/calc.fix' + turns = self.daemon.request('turns', bot='calc.fix')['result']['turns'] + brief = self.daemon.request('prompts', bot='calc.fix', turn=turns[0]['turn'])['result']['prompts'][0]['text'] + self.assertEqual(launch['arguments']['title'], 'fix') + self.assertEqual(launch['arguments']['message'], brief) + self.assertEqual(launch['arguments']['workspaceStrategy'], + {'type': 'existing_worktree', 'worktreePath': str(worktree)}) + self.assertTrue(all(h.get('authorization') == 'Bearer thread-1' + or h.get('Authorization') == 'Bearer thread-1' for h in self.t3.headers)) + + # That thread is the task's, not a lead made for its folder: it + # answers with the task's work. + child, child_session = self.open(worktree) + answered, updates = child.answer_of(child.ask('session/prompt', { + 'sessionId': child_session, 'prompt': self.wrapped(brief)})) + self.assertEqual(answered['result']['stopReason'], 'end_turn') + self.assertTrue([u for u in updates if u['sessionUpdate'] == 'agent_message_chunk']) + kept = self.path / '.agent/sonny/t3/sessions' / child_session + self.assertEqual(kept.read_text(), 'calc.fix') + self.assertEqual([b['name'] for b in self.daemon.request('bots')['result']['bots']], + ['calc.fix', 'calc.lead']) + + def test_a_turn_the_lead_takes_on_its_own_shows_in_the_thread(self): + repo = self.repo() + self.daemon.request('create', bot='calc.lead', workspace=str(repo), model='openai/synthetic-model', + instructions='Lead.') + thread, session = self.open(repo) + answered, _ = thread.answer_of(thread.ask('session/prompt', {'sessionId': session, + 'prompt': self.wrapped('hello')})) + self.assertEqual(answered['result'], {'stopReason': 'end_turn'}) + # The lookout wakes the lead, as when a task finished: no prompt of + # the thread's, so the thread is told what started it. + self.daemon.request('submit', bot='calc.lead', request_id='lookout-1', prompt='fix finished') + seen = thread.until(lambda m: m.get('params', {}).get('update', {}).get('state') == 'idle') + updates = [m['params']['update'] for m in seen] + self.assertEqual(updates[0], {'sessionUpdate': 'state_update', 'state': 'running'}) + [said] = [u for u in updates if u['sessionUpdate'] == 'user_message'] + self.assertEqual(said['content'], [{'type': 'text', 'text': 'fix finished'}]) + self.assertTrue([u for u in updates if u['sessionUpdate'] == 'agent_message_chunk']) From 6d570cb7f12f0256373512382cfa7bded3c32b4e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 01:21:35 +0000 Subject: [PATCH 04/17] cmux-tabs: point agents at cmux's Automation mode when it refuses them Agents' shells run in the daemon, which cmux did not start, so cmux's default "cmux processes only" mode refuses them. The skill and README now name the setting that lets them in, and say a workspace the person asks for is a project's. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFMvxuKK3qzEb4j48d --- sonny/README.md | 8 +++++--- sonny/skills/cmux-tabs/SKILL.md | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/sonny/README.md b/sonny/README.md index f3835c46a..2375b2740 100644 --- a/sonny/README.md +++ b/sonny/README.md @@ -139,9 +139,11 @@ command (`cmux surface resume set`, shown as `Sonny: NAME`), so after cmux relaunches the tab runs `sonny` on the same bot again, once you allow commands starting with `sonny` from the tab's Resume Commands menu (until then cmux offers it to run by hand); quitting Sonny clears it. cmux lets -only programs started in its tabs reach it, by default: the lookout Sonny -starts there is one, and one started outside cmux needs Settings > -Automation set to allow local automation. +only programs started in its tabs reach it, by default; agents' shells run +in the daemon, not in cmux, so set cmux's Settings > Automation to +"Automation mode" (your own programs, no ancestry check) for agents to run +commands in tabs and read them. The lookout Sonny starts in cmux reaches it +either way; one started outside cmux needs Automation mode too. The place a project was laid out in is kept in `~/.agent/sonny/places`, so an agent's `run_in_pane` and `show_agent` calls go to that Herdr or cmux workspace or diff --git a/sonny/skills/cmux-tabs/SKILL.md b/sonny/skills/cmux-tabs/SKILL.md index 7dead625a..48f923a1b 100644 --- a/sonny/skills/cmux-tabs/SKILL.md +++ b/sonny/skills/cmux-tabs/SKILL.md @@ -9,7 +9,7 @@ cmux is the terminal the person watches the work in. Your shell is not in a cmux A tab is the default. It is one more stream of work, beside the lead's tab, that the person can click into when they choose, to watch it, steer it and manage it there. A split puts something beside you, in the workspace the person is looking at, so it asks for their attention now. Open one only when you want them to see something right away: a server or a CLI's output you are showing them, or an agent they should watch or talk to side by side with you. When unsure, use a tab. -Workspaces are for leads: each project's lead has a cmux workspace of its own, and its agents and commands get tabs and splits in it. +Workspaces are for leads: each project's lead has a cmux workspace of its own, and its agents and commands get tabs and splits in it. When the person asks for a workspace, they mean a project's: open another project's lead in its own with `show_agent`, or make the project with `new_project` and then show its lead. ## Where things go @@ -42,4 +42,4 @@ Close each tab you opened once you have what you need from it: a build or test r ## When cmux refuses -cmux lets only programs it started, or ones the person allows in cmux's Settings > Automation, reach it. When a call says cmux refused or denied the connection, tell the person, with what cmux said; never change cmux's settings yourself. +Your shell is not a program cmux started, so cmux takes its calls only in Automation mode, which lets the person's own programs reach it. When a call says cmux refused or denied the connection ("only processes started inside cmux can connect"), tell the person, with what cmux said, that cmux's Settings > Automation needs "Automation mode" for agents to reach it; never change cmux's settings yourself. From 764ee118c8d0b7e502536e2f0432e81badc37db3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 01:26:17 +0000 Subject: [PATCH 05/17] sonny acp: whole call arguments, waiting approvals, claims by folder, Claude's tool results - A call whose event carried only the start of its arguments is read from history, so a long start_task still opens the task's thread. - A thread that comes to a bot already waiting for the person is asked for those calls. - A task thread claims the task waiting for its message in its folder; the same brief twice in one folder is told apart by the task's name. - A resumed thread Sonny has no binding for is refused, not made a lead. - History items go through `responses`, so Anthropic-family bots' tool results show and their start_task opens threads. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFMvxuKK3qzEb4j48d --- sonny/src/acp.rs | 72 +++++++++++++++++++----- sonny/src/t3.rs | 140 +++++++++++++++++++++++++++++++++------------- tests/test_acp.py | 9 +++ 3 files changed, 168 insertions(+), 53 deletions(-) diff --git a/sonny/src/acp.rs b/sonny/src/acp.rs index 7f5054a34..1c28ac3b1 100644 --- a/sonny/src/acp.rs +++ b/sonny/src/acp.rs @@ -284,9 +284,15 @@ impl Agent { if let Ok(mut sessions) = self.sessions.lock() { sessions.insert(id.clone(), session.clone()); } - if let Some(bot) = resumed.and_then(t3::recall) - && let Err(error) = session.bind(self, Some(bot), false).await - { + // A thread comes back as the bot it was, or not at all. + let bound = match resumed { + Some(id) => match t3::recall(id) { + Some(bot) => session.bind(self, Some(bot), false).await, + None => Err(format!("Sonny does not know thread {id}")), + }, + None => Ok(()), + }; + if let Err(error) = bound { if let Ok(mut sessions) = self.sessions.lock() { sessions.remove(&id); } @@ -389,7 +395,18 @@ impl Session { } } let (session, agent) = (self.clone(), agent.clone()); - tokio::spawn(async move { session.follow(&agent, events).await }); + // Calls already waiting for the person, whose events were before + // this thread came, are put to it too. + let waiting = { + let state = self.state.lock().await; + (state.bound.as_ref()).map(|b| (b.client.clone(), b.bot.clone(), state.running)) + }; + tokio::spawn(async move { + if let Some((client, bot, turn @ Some(_))) = waiting { + session.approvals(&agent, &client, &bot, turn).await; + } + session.follow(&agent, events).await + }); Ok(()) } @@ -400,7 +417,7 @@ impl Session { if !bound { // The first prompt of a thread T3 Code opened for a task is // that task's brief. - let claimed = t3::claim(&text); + let claimed = t3::claim(&text, &self.cwd); let task = claimed.is_some(); // A task's thread stands for the turn the task runs already. if task { @@ -603,10 +620,13 @@ impl Session { "tool_started" => { let name = data["name"].as_str().unwrap_or("tool").to_owned(); let call = data["call_id"].as_str().unwrap_or("").to_owned(); - let arguments: Value = data["arguments"] - .as_str() - .and_then(|a| serde_json::from_str(a).ok()) - .unwrap_or(Value::Null); + let arguments: Value = match data["arguments_truncated"] == true { + true => whole_arguments(&client, &bot, &call).await, + false => data["arguments"] + .as_str() + .and_then(|a| serde_json::from_str(a).ok()) + .unwrap_or(Value::Null), + }; state.part += 1; state.streamed = false; if name == reactions::TOOL { @@ -915,9 +935,9 @@ async fn open_task( .as_str() .ok_or("the task has no brief")? .to_owned(); - t3::expect(&bot, &brief)?; let workspace = record["workspace"].as_str().unwrap_or_default(); - let mut launch = json!({"title": name, "message": brief, "clientRequestId": bot}); + let message = t3::expect(&bot, &brief, Path::new(workspace))?; + let mut launch = json!({"title": name, "message": message, "clientRequestId": bot}); if workspace != lead_workspace { launch["workspaceStrategy"] = json!({"type": "existing_worktree", "worktreePath": workspace}); @@ -926,9 +946,8 @@ async fn open_task( // A folder T3 Code does not take as one of the project's: the // thread works in the project's, as the task's still says. Err(_) if !launch["workspaceStrategy"].is_null() => { - if let Some(fields) = launch.as_object_mut() { - fields.remove("workspaceStrategy"); - } + let message = t3::expect(&bot, &brief, Path::new(lead_workspace))?; + let launch = json!({"title": name, "message": message, "clientRequestId": bot}); t3.call("t3_thread_launch", launch).await } launched => launched, @@ -1057,12 +1076,35 @@ async fn items(client: &Client, bot: &str, nodes: &[i64]) -> Vec { .as_array() .into_iter() .flatten() - .map(|entry| entry["item"].clone()) + .flat_map(|entry| crate::responses(&entry["item"])) .collect(), Err(_) => Vec::new(), } } +/// Call `id`'s whole arguments, from the bot's newest items, for one whose +/// event carried only their start. +async fn whole_arguments(client: &Client, bot: &str, id: &str) -> Value { + let Ok(page) = client + .request("history_nodes", json!({"bot": bot, "limit": 64})) + .await + else { + return Value::Null; + }; + let nodes: Vec = page["nodes"] + .as_array() + .into_iter() + .flatten() + .filter_map(|node| node["node"].as_i64()) + .collect(); + items(client, bot, &nodes) + .await + .into_iter() + .find(|item| item["type"] == "function_call" && item["call_id"] == id) + .and_then(|call| serde_json::from_str(call["arguments"].as_str()?).ok()) + .unwrap_or(Value::Null) +} + #[cfg(test)] mod tests { use super::*; diff --git a/sonny/src/t3.rs b/sonny/src/t3.rs index 8ad8befc8..22e02f544 100644 --- a/sonny/src/t3.rs +++ b/sonny/src/t3.rs @@ -3,7 +3,7 @@ //! lead's thread opens a thread for each task the lead starts; the tasks //! waiting for their thread, and the bot each thread is. use serde_json::{Value, json}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; /// The MCP protocol version Sonny asks T3 Code's endpoint for, as T3 /// Code's own terminal fallback does. @@ -202,16 +202,68 @@ fn file_name(bot: &str) -> String { bot.replace('/', "%2F") } -/// The task `bot` waits for the thread opened with `brief`. -pub fn expect(bot: &str, brief: &str) -> Result<(), String> { - let dir = dir("pending").ok_or("HOME is not set")?; - std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; - let now = std::time::SystemTime::now() +fn now() -> u64 { + std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() - .as_secs(); - let record = json!({"bot": bot, "brief": brief, "at": now}); - std::fs::write(dir.join(file_name(bot)), record.to_string()).map_err(|e| e.to_string()) + .as_secs() +} + +/// A folder as T3 Code and the daemon may each spell it. +fn same_folder(a: &str, b: &Path) -> bool { + let real = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_owned()); + real(Path::new(a)) == real(b) +} + +/// The tasks waiting for their thread, oldest first; ones waiting too +/// long go. +fn pending(dir: &Path) -> Vec<(PathBuf, Value)> { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let now = now(); + let mut all: Vec<(PathBuf, Value)> = entries + .flatten() + .filter_map(|entry| { + let record = + serde_json::from_str::(&std::fs::read_to_string(entry.path()).ok()?).ok()?; + Some((entry.path(), record)) + }) + .filter(|(path, record)| { + let fresh = now.saturating_sub(record["at"].as_u64().unwrap_or(0)) <= PENDING_SECS; + if !fresh { + let _ = std::fs::remove_file(path); + } + fresh + }) + .collect(); + all.sort_by_key(|(_, record)| record["at"].as_u64()); + all +} + +/// The task `bot` waits for the thread T3 Code opens in `cwd`: the first +/// message to open it with, the brief, told apart by the task's name from +/// another task's waiting with the same brief in the same folder. +pub fn expect(bot: &str, brief: &str, cwd: &Path) -> Result { + expect_in(&dir("pending").ok_or("HOME is not set")?, bot, brief, cwd) +} + +fn expect_in(dir: &Path, bot: &str, brief: &str, cwd: &Path) -> Result { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + let taken = pending(dir).into_iter().any(|(_, record)| { + record["bot"] != bot + && record["message"].as_str() == Some(brief) + && record["cwd"] + .as_str() + .is_some_and(|at| same_folder(at, cwd)) + }); + let message = match taken { + true => format!("{brief}\n\n({bot})"), + false => brief.to_owned(), + }; + let record = json!({"bot": bot, "message": message, "cwd": cwd, "at": now()}); + std::fs::write(dir.join(file_name(bot)), record.to_string()).map_err(|e| e.to_string())?; + Ok(message) } /// No thread comes for `bot`. @@ -221,35 +273,23 @@ pub fn forget(bot: &str) { } } -/// The task a thread's first prompt opens: the one waiting with that -/// brief, which no other thread then claims. Ones waiting too long go. -pub fn claim(prompt: &str) -> Option { - let dir = dir("pending")?; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let mut found = None; - for entry in std::fs::read_dir(&dir).ok()?.flatten() { - let Ok(text) = std::fs::read_to_string(entry.path()) else { - continue; - }; - let Ok(record) = serde_json::from_str::(&text) else { - continue; - }; - if now.saturating_sub(record["at"].as_u64().unwrap_or(0)) > PENDING_SECS { - let _ = std::fs::remove_file(entry.path()); - continue; - } - if found.is_none() - && record["brief"].as_str().map(str::trim) == Some(prompt.trim()) - // Claimed by whoever removes it. - && std::fs::remove_file(entry.path()).is_ok() - { - found = record["bot"].as_str().map(str::to_owned); - } - } - found +/// The task a thread in `cwd` opens with its first prompt: the one waiting +/// for that message there, which no other thread then claims. +pub fn claim(prompt: &str, cwd: &Path) -> Option { + claim_in(&dir("pending")?, prompt, cwd) +} + +fn claim_in(dir: &Path, prompt: &str, cwd: &Path) -> Option { + pending(dir).into_iter().find_map(|(path, record)| { + let ours = record["message"].as_str().map(str::trim) == Some(prompt.trim()) + && record["cwd"] + .as_str() + .is_some_and(|at| same_folder(at, cwd)); + // Claimed by whoever removes it. + (ours && std::fs::remove_file(&path).is_ok()) + .then(|| record["bot"].as_str().map(str::to_owned)) + .flatten() + }) } /// The bot the thread `session` is, for its next start. @@ -283,6 +323,30 @@ mod tests { assert_eq!(request("Fix it."), "Fix it."); } + #[test] + fn a_thread_claims_the_task_waiting_for_its_message_in_its_folder() { + let home = std::env::temp_dir().join(format!("sonny-t3-{}", std::process::id())); + let (dir, one, two) = (home.join("pending"), home.join("one"), home.join("two")); + std::fs::create_dir_all(&one).unwrap(); + std::fs::create_dir_all(&two).unwrap(); + assert_eq!( + expect_in(&dir, "a.fix", "Fix it.", &one).unwrap(), + "Fix it." + ); + assert_eq!( + expect_in(&dir, "b.fix", "Fix it.", &two).unwrap(), + "Fix it." + ); + // The same brief in the same folder is told apart by its name. + let other = expect_in(&dir, "a.other", "Fix it.", &one).unwrap(); + assert_eq!(other, "Fix it.\n\n(a.other)"); + assert_eq!(claim_in(&dir, "Fix it.", &two).as_deref(), Some("b.fix")); + assert_eq!(claim_in(&dir, "Fix it.", &two), None); + assert_eq!(claim_in(&dir, &other, &one).as_deref(), Some("a.other")); + assert_eq!(claim_in(&dir, " Fix it.\n", &one).as_deref(), Some("a.fix")); + let _ = std::fs::remove_dir_all(&home); + } + #[test] fn a_reply_is_json_or_server_sent_events() { assert_eq!(payloads(r#"{"id":1}"#), vec![json!({"id": 1})]); diff --git a/tests/test_acp.py b/tests/test_acp.py index 1bf20398d..7ef40321b 100644 --- a/tests/test_acp.py +++ b/tests/test_acp.py @@ -227,3 +227,12 @@ def test_a_turn_the_lead_takes_on_its_own_shows_in_the_thread(self): [said] = [u for u in updates if u['sessionUpdate'] == 'user_message'] self.assertEqual(said['content'], [{'type': 'text', 'text': 'fix finished'}]) self.assertTrue([u for u in updates if u['sessionUpdate'] == 'agent_message_chunk']) + + def test_a_thread_sonny_does_not_know_does_not_come_back(self): + repo = self.repo() + thread = Thread(self, self.env, repo) + thread.answer_of(thread.ask('initialize', {'protocolVersion': 2, 'clientCapabilities': {}})) + refused, _ = thread.answer_of(thread.ask('session/resume', {'sessionId': 'sonny-gone', 'cwd': str(repo), + 'mcpServers': []})) + self.assertIn('sonny-gone', refused['error']['message']) + self.assertEqual(self.daemon.request('bots')['result']['bots'], []) From 590b2af67c75c74a1fe08cbe0228278bc9d0acf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 01:29:21 +0000 Subject: [PATCH 06/17] sonny acp: approve only what was shown whole; follow again when the stream drops - A call listed with its arguments cut is shown whole, read from the node that planned it, before the person may allow it; when that read fails, the person may only deny it. - When the daemon lets a thread's stream go (follow_lagged, or the connection ends), the thread follows its bot again, answers a prompt whose turn ended meanwhile, and asks for calls waiting on the person; a prompt is failed rather than left waiting while the daemon is away. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFMvxuKK3qzEb4j48d --- sonny/src/acp.rs | 122 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 109 insertions(+), 13 deletions(-) diff --git a/sonny/src/acp.rs b/sonny/src/acp.rs index 1c28ac3b1..7d92fa031 100644 --- a/sonny/src/acp.rs +++ b/sonny/src/acp.rs @@ -526,16 +526,91 @@ impl Session { /// The bot's events, as this thread's updates, until it is closed. async fn follow(self: Arc, agent: &Arc, mut events: Events) { - while let Some(event) = events.recv().await { - let open = agent + let open = || { + agent .sessions .lock() - .is_ok_and(|s| s.contains_key(&self.id)); - if !open { - break; + .is_ok_and(|s| s.contains_key(&self.id)) + }; + loop { + while let Some(event) = events.recv().await { + if !open() { + return; + } + // The daemon let this stream go, as when it fell behind. + if event["event"] == "follow_lagged" { + break; + } + self.on(agent, &event).await; + } + let mut failed = false; + events = loop { + if !open() { + return; + } + match self.reattach(agent).await { + Ok(events) => break events, + Err(error) => { + // The person's prompt is not left waiting while the + // daemon comes back. + if !failed && let Some((prompt, _)) = self.state.lock().await.prompt.take() + { + agent.out.fail(&prompt, FAILED, &error); + } + failed = true; + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + } + }; + } + } + + /// Follow the bot again on a new connection, and settle what happened + /// while this thread was not following: a turn that ended, calls that + /// wait for the person. + async fn reattach(&self, agent: &Arc) -> Result { + let bot = (self.state.lock().await.bound.as_ref()) + .map(|b| b.bot.clone()) + .ok_or("the thread has no bot")?; + let (client, mut events) = Client::connect(&agent.options.socket) + .await + .map_err(|e| e.to_string())?; + client + .request("follow", json!({"bot": bot, "after": 0})) + .await + .map_err(|e| e.to_string())?; + crate::caught_up(&mut events).await; + let record = client + .request("inspect", json!({"bot": bot})) + .await + .map_err(|e| e.to_string())?; + let running = record["running_turn"].as_i64(); + { + let mut state = self.state.lock().await; + if let Some(bound) = &mut state.bound { + bound.client = client.clone(); + } + let waited = state.prompt.as_ref().and_then(|(_, turn)| *turn); + let ended = |turn: Option| turn.is_some() && running != turn; + if ended(state.running) || ended(waited) { + if agent.v2() { + agent.out.update( + &self.id, + json!({"sessionUpdate": "state_update", + "state": "idle", "stopReason": "end_turn"}), + ); + } + if let Some((prompt, _)) = state.prompt.take() { + agent.out.answer(&prompt, json!({"stopReason": "end_turn"})); + } + state.calls.clear(); } - self.on(agent, &event).await; + state.running = running; + } + if running.is_some() { + self.approvals(agent, &client, &bot, running).await; } + Ok(events) } async fn on(&self, agent: &Arc, event: &Value) { @@ -776,16 +851,37 @@ impl Session { async fn ask(agent: &Agent, session: &str, client: &Client, bot: &str, call: &Value) { let id = call["call_id"].as_str().unwrap_or(""); let name = call["name"].as_str().unwrap_or("tool"); - let mut update = tool_call(id, name, &call["arguments"]); - update["sessionUpdate"] = Value::Null; + // A long call is listed cut: the person is shown the whole of it, read + // from the node that planned it, or may only deny it. + let listed_whole = call["arguments"].is_object() + && call["arguments_cut"].as_array().is_none_or(Vec::is_empty) + && call["arguments_omitted"].as_u64().unwrap_or(0) == 0; + let arguments = match listed_whole { + true => Some(call["arguments"].clone()), + false => client + .request( + "history_items", + json!({"bot": bot, "nodes": [call["node"]]}), + ) + .await + .ok() + .and_then(|read| agent_client::approver::call_arguments(&read["items"][0]["item"], id)), + }; + let mut update = tool_call(id, name, arguments.as_ref().unwrap_or(&Value::Null)); if let Value::Object(fields) = &mut update { fields.remove("sessionUpdate"); } - let options = json!([ - {"optionId": "allow", "name": "Allow", "kind": "allow_once"}, - {"optionId": "deny", "name": "Deny", "kind": "reject_once"} - ]); - let title = update["title"].as_str().unwrap_or(name).to_owned(); + let mut options = vec![json!({"optionId": "deny", "name": "Deny", "kind": "reject_once"})]; + let mut title = update["title"].as_str().unwrap_or(name).to_owned(); + match arguments { + Some(_) => options.insert( + 0, + json!({"optionId": "allow", "name": "Allow", "kind": "allow_once"}), + ), + None => { + title = format!("{title} (its whole call could not be read, so it can only be denied)") + } + } let answer = agent .out .ask( From 1ac36734b1e05ad6e9b5eab64aafdb5abe7b1dac Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 01:39:51 +0000 Subject: [PATCH 07/17] sonny acp: stop before the turn is known, task names as start_task names them, services on resume, no missed events, one follower - A cancel that comes before the prompt's turn is known is kept: the prompt is not sent, or its turn is interrupted once it is. - A task named with its project already (`calc.fix`) is found as `start_task` names it, not as `calc.calc.fix`. - A thread that comes back to a saved bot starts what it needs: the lookout, the lead's subscription, and the judge for an `auto` gate. - A thread whose stream dropped follows on from the last event it handled, so what happened meanwhile (a task started) is handled too. - Closing or replacing a thread stops its follower. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFMvxuKK3qzEb4j48d --- sonny/src/acp.rs | 171 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 148 insertions(+), 23 deletions(-) diff --git a/sonny/src/acp.rs b/sonny/src/acp.rs index 7d92fa031..ea71df3ff 100644 --- a/sonny/src/acp.rs +++ b/sonny/src/acp.rs @@ -197,10 +197,14 @@ impl Agent { None => Err((INVALID_PARAMS, "no such session".into())), }, "session/close" => { - if let Some(session) = params["sessionId"].as_str() - && let Ok(mut sessions) = self.sessions.lock() - { - sessions.remove(session); + let closed = params["sessionId"].as_str().and_then(|session| { + self.sessions + .lock() + .ok() + .and_then(|mut s| s.remove(session)) + }); + if let Some(closed) = closed { + closed.stop(); } Ok(json!({})) } @@ -279,10 +283,16 @@ impl Agent { id: id.clone(), cwd, state: AsyncMutex::new(State::default()), + follower: Mutex::new(None), }); - // Open before it follows its bot: a closed thread stops following. - if let Ok(mut sessions) = self.sessions.lock() { - sessions.insert(id.clone(), session.clone()); + // Open before it follows its bot; one it replaces stops following. + let replaced = self + .sessions + .lock() + .ok() + .and_then(|mut sessions| sessions.insert(id.clone(), session.clone())); + if let Some(old) = replaced { + old.stop(); } // A thread comes back as the bot it was, or not at all. let bound = match resumed { @@ -313,6 +323,16 @@ struct Session { id: String, cwd: PathBuf, state: AsyncMutex, + /// What follows its bot, stopped when the thread closes. + follower: Mutex>, +} + +impl Session { + fn stop(&self) { + if let Some(follower) = self.follower.lock().ok().and_then(|mut f| f.take()) { + follower.abort(); + } + } } #[derive(Default)] @@ -330,6 +350,11 @@ struct State { calls: HashMap, /// Calls put to the person, by call and request. asked: HashSet, + /// The last of the bot's kept events this thread handled, to follow on + /// from when its stream drops. + cursor: i64, + /// The person stopped the prompt before its turn was known. + cancelled: bool, } struct Bound { @@ -358,6 +383,7 @@ impl Session { let (bot, project, moving) = match bot { Some(bot) => { let project = bot.strip_suffix(".lead").map(str::to_owned); + services(agent, &client, &bot, project.as_deref()).await?; (bot, project, None) } None => { @@ -371,7 +397,7 @@ impl Session { .request("follow", json!({"bot": bot, "after": 0})) .await .map_err(|e| e.to_string())?; - crate::caught_up(&mut events).await; + let cursor = live(&mut events, |_| async {}).await; let record = client .request("inspect", json!({"bot": bot})) .await @@ -381,6 +407,7 @@ impl Session { { let mut state = self.state.lock().await; state.running = record["running_turn"].as_i64(); + state.cursor = cursor; state.bound = Some(Bound { bot, client, @@ -401,19 +428,29 @@ impl Session { let state = self.state.lock().await; (state.bound.as_ref()).map(|b| (b.client.clone(), b.bot.clone(), state.running)) }; - tokio::spawn(async move { + let follower = self.clone(); + let handle = tokio::spawn(async move { if let Some((client, bot, turn @ Some(_))) = waiting { - session.approvals(&agent, &client, &bot, turn).await; + follower.approvals(&agent, &client, &bot, turn).await; } - session.follow(&agent, events).await + follower.follow(&agent, events).await }); + if let Ok(mut old) = session.follower.lock() + && let Some(old) = old.replace(handle.abort_handle()) + { + old.abort(); + } Ok(()) } async fn prompt(self: &Arc, agent: &Arc, id: &Value, params: &Value) -> Reply { let (text, images) = words(¶ms["prompt"]); let text = t3::request(&text).to_owned(); - let bound = self.state.lock().await.bound.is_some(); + let bound = { + let mut state = self.state.lock().await; + state.cancelled = false; + state.bound.is_some() + }; if !bound { // The first prompt of a thread T3 Code opened for a task is // that task's brief. @@ -446,6 +483,9 @@ impl Session { } } let mut state = self.state.lock().await; + if std::mem::take(&mut state.cancelled) { + return Ok(json!({"stopReason": "cancelled"})); + } let Some(bound) = &state.bound else { return Err((FAILED, "the thread has no bot".into())); }; @@ -474,6 +514,14 @@ impl Session { { bound.moving = None; } + // Stopped while it was being sent. + if std::mem::take(&mut state.cancelled) + && let (Some(bound), Some(turn)) = (&state.bound, reply["turn"].as_i64()) + { + let _ = (bound.client) + .request("interrupt", json!({"bot": bound.bot, "turn": turn})) + .await; + } if let Some((_, turn @ None)) = &mut state.prompt { *turn = reply["turn"].as_i64(); } @@ -487,11 +535,19 @@ impl Session { } async fn cancel(&self) { - let state = self.state.lock().await; - if let (Some(bound), Some(turn)) = (&state.bound, state.running) { - let _ = (bound.client) - .request("interrupt", json!({"bot": bound.bot, "turn": turn})) - .await; + let mut state = self.state.lock().await; + let turn = state + .running + .or(state.prompt.as_ref().and_then(|(_, turn)| *turn)); + match (&state.bound, turn) { + (Some(bound), Some(turn)) => { + let _ = (bound.client) + .request("interrupt", json!({"bot": bound.bot, "turn": turn})) + .await; + } + // Its turn is not known yet: it is stopped once it is. + _ if state.prompt.is_some() || state.bound.is_none() => state.cancelled = true, + _ => {} } } @@ -575,11 +631,24 @@ impl Session { let (client, mut events) = Client::connect(&agent.options.socket) .await .map_err(|e| e.to_string())?; + let after = { + let mut state = self.state.lock().await; + if let Some(bound) = &mut state.bound { + bound.client = client.clone(); + } + state.cursor + }; client - .request("follow", json!({"bot": bot, "after": 0})) + .request("follow", json!({"bot": bot, "after": after})) .await .map_err(|e| e.to_string())?; - crate::caught_up(&mut events).await; + // What was kept while the thread was away is handled as it would + // have been: a task started then still opens its thread. + let cursor = live( + &mut events, + |event| async move { self.on(agent, &event).await }, + ) + .await; let record = client .request("inspect", json!({"bot": bot})) .await @@ -587,9 +656,7 @@ impl Session { let running = record["running_turn"].as_i64(); { let mut state = self.state.lock().await; - if let Some(bound) = &mut state.bound { - bound.client = client.clone(); - } + state.cursor = state.cursor.max(cursor); let waited = state.prompt.as_ref().and_then(|(_, turn)| *turn); let ended = |turn: Option| turn.is_some() && running != turn; if ended(state.running) || ended(waited) { @@ -620,6 +687,9 @@ impl Session { return; } let (client, bot) = (bound.client.clone(), bound.bot.clone()); + if let Some(cursor) = event["cursor"].as_i64() { + state.cursor = state.cursor.max(cursor); + } let turn = event["turn"].as_i64(); let data = &event["data"]; let out = &agent.out; @@ -907,6 +977,55 @@ async fn ask(agent: &Agent, session: &str, client: &Client, bot: &str, call: &Va .await; } +/// Handle the events a follow replays, until it is live: the cursor it is +/// live from. +async fn live(events: &mut Events, mut each: F) -> i64 +where + F: FnMut(Value) -> R, + R: std::future::Future, +{ + let replay = async { + while let Some(event) = events.recv().await { + if event["event"] == "follow_live" { + return event["cursor"].as_i64().unwrap_or(0); + } + each(event).await; + } + 0 + }; + tokio::time::timeout(std::time::Duration::from_secs(30), replay) + .await + .unwrap_or(0) +} + +/// What a bot this thread comes back to needs running, as when its lead +/// was set up: the lookout watching the lead's tasks and answering +/// Sonny's tools, and a judge for an `auto` gate. +async fn services( + agent: &Agent, + client: &Client, + bot: &str, + project: Option<&str>, +) -> Result<(), String> { + let record = client + .request("inspect", json!({"bot": bot})) + .await + .map_err(|e| e.to_string())?; + if let Some(project) = project { + lookout::subscribe_lead(&agent.options.store, bot, project)?; + } + crate::start_lookout(&agent.options)?; + if approvals::mode(&record) == Some("auto") { + let model = format!( + "{}/{}", + record["provider"].as_str().unwrap_or(""), + record["model"].as_str().unwrap_or("") + ); + crate::serve_auto(client, &agent.options, &model).await?; + } + Ok(()) +} + /// Set up the lead a thread is, as `sonny` sets up the lead it opens: the /// tools Sonny gives it, its folder's rules and skills as they are today, /// the approval mode a new lead starts in, the lookout watching its tasks, @@ -1012,7 +1131,13 @@ async fn open_task( let Some(name) = arguments["name"].as_str() else { return; }; - let bot = format!("{project}.{name}"); + // Named as `start_task` names it. + let short = name.trim(); + let bot = match short.strip_prefix(&format!("{project}.")) { + Some(_) => short.to_owned(), + None => format!("{project}.{short}"), + }; + let name = bot.strip_prefix(&format!("{project}.")).unwrap_or(&bot); let opened = async { let record = client .request("inspect", json!({"bot": bot})) From 8aa8984166b31dd7c41bc965ce8fba9b637a6ec1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 01:55:57 +0000 Subject: [PATCH 08/17] sonny acp: calls in flight on bind, the rest of a dropped reply, every approval page, answers on the live connection, steers by their own turn - Binding to a running bot keeps the running turn's started calls from the replay, so a start_task finishing after the thread came opens its thread. - A reply whose stream dropped partway sends the rest from the kept message instead of stopping at what streamed. - Approvals are listed page by page. - A permission answer goes through the thread's connection as it is when the person chooses; one not delivered is asked again on reattach. - A prompt that steers waits on the turn the daemon names for it, and on the turn that takes it in once steered; a turn finishing before then no longer answers it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFMvxuKK3qzEb4j48d --- sonny/src/acp.rs | 189 +++++++++++++++++++++++++++++++++++----------- tests/test_acp.py | 23 ++++++ 2 files changed, 169 insertions(+), 43 deletions(-) diff --git a/sonny/src/acp.rs b/sonny/src/acp.rs index ea71df3ff..a7c99d042 100644 --- a/sonny/src/acp.rs +++ b/sonny/src/acp.rs @@ -346,7 +346,8 @@ struct State { /// Words since the last call, by kind, and the part of the turn they /// are in: a new message after each call. part: u32, - streamed: bool, + /// The words of this part sent as they streamed. + streamed: String, calls: HashMap, /// Calls put to the person, by call and request. asked: HashSet, @@ -355,6 +356,9 @@ struct State { cursor: i64, /// The person stopped the prompt before its turn was known. cancelled: bool, + /// The last steer a running turn took in, and that turn: for a prompt + /// whose steer went in before its turn was named. + steered: Option<(i64, i64)>, } struct Bound { @@ -397,16 +401,43 @@ impl Session { .request("follow", json!({"bot": bot, "after": 0})) .await .map_err(|e| e.to_string())?; - let cursor = live(&mut events, |_| async {}).await; + // Calls started before this thread came and not done yet: a task a + // lead is starting still opens its thread when it is done. + let mut started = HashMap::new(); + let cursor = live(&mut events, |event| { + let call = event["data"]["call_id"].as_str().unwrap_or("").to_owned(); + match event["event"].as_str() { + Some("tool_started") => { + started.insert(call, (event["turn"].as_i64(), event["data"].clone())); + } + Some("tool_completed") => { + started.remove(&call); + } + _ => {} + } + async {} + }) + .await; let record = client .request("inspect", json!({"bot": bot})) .await .map_err(|e| e.to_string())?; let workspace = record["workspace"].as_str().unwrap_or_default().to_owned(); + let running = record["running_turn"].as_i64(); + let mut calls = HashMap::new(); + for (turn, data) in started.into_values() { + if running.is_some() && turn == running { + let (call, name, arguments) = started_call(&client, &bot, &data).await; + if name != reactions::TOOL && name != plan::TOOL { + calls.insert(call, (name, arguments)); + } + } + } t3::remember(&self.id, &bot); { let mut state = self.state.lock().await; - state.running = record["running_turn"].as_i64(); + state.running = running; + state.calls = calls; state.cursor = cursor; state.bound = Some(Bound { bot, @@ -498,9 +529,9 @@ impl Session { } let client = bound.client.clone(); // A prompt that steers joins the turn an earlier one waits on, - // which is answered now: its words are in. - let running = state.running; - if let Some((earlier, _)) = state.prompt.replace((id.clone(), running)) { + // which is answered now: its words are in. Its own turn is the one + // the daemon names for it, which the running turn may take in. + if let Some((earlier, _)) = state.prompt.replace((id.clone(), None)) { agent .out .answer(&earlier, json!({"stopReason": "end_turn"})); @@ -522,8 +553,14 @@ impl Session { .request("interrupt", json!({"bot": bound.bot, "turn": turn})) .await; } - if let Some((_, turn @ None)) = &mut state.prompt { - *turn = reply["turn"].as_i64(); + let steered = state.steered; + if let Some((prompt, turn)) = &mut state.prompt + && prompt == id + { + *turn = match (reply["turn"].as_i64(), steered) { + (Some(sent), Some((steer, into))) if sent == steer => Some(into), + (sent, _) => sent, + }; } Ok(Value::Null) } @@ -657,9 +694,10 @@ impl Session { { let mut state = self.state.lock().await; state.cursor = state.cursor.max(cursor); - let waited = state.prompt.as_ref().and_then(|(_, turn)| *turn); - let ended = |turn: Option| turn.is_some() && running != turn; - if ended(state.running) || ended(waited) { + // The replay ended what it saw end; a turn it could not show + // (its events gone) ended if the daemon runs another. + let was = state.running; + if was.is_some() && running != was { if agent.v2() { agent.out.update( &self.id, @@ -667,7 +705,9 @@ impl Session { "state": "idle", "stopReason": "end_turn"}), ); } - if let Some((prompt, _)) = state.prompt.take() { + if matches!(&state.prompt, Some((_, at)) if *at == was) + && let Some((prompt, _)) = state.prompt.take() + { agent.out.answer(&prompt, json!({"stopReason": "end_turn"})); } state.calls.clear(); @@ -699,7 +739,7 @@ impl Session { // A steer joins the running turn, whose calls go on. if state.running != turn { state.part = 0; - state.streamed = false; + state.streamed.clear(); state.calls.clear(); } state.running = turn; @@ -733,8 +773,8 @@ impl Session { } } "text_delta" => { - state.streamed = true; let text = event["text"].as_str().unwrap_or(""); + state.streamed.push_str(text); out.update(id, json!({"sessionUpdate": "agent_message_chunk", "messageId": message_id(&bot, turn, state.part), "content": {"type": "text", "text": text}})); } @@ -747,33 +787,28 @@ impl Session { "content": {"type": "text", "text": text}}), ); } - // Words that did not stream, as from a thread that came late. - "message" if !state.streamed => { + // Words that did not stream, as from a thread that came late or + // whose stream dropped partway: what was not sent yet. + "message" => { + let streamed = std::mem::take(&mut state.streamed); if let Some(node) = data["node"].as_i64() && let Some(item) = items(&client, &bot, &[node]).await.into_iter().next() && item["role"] == "assistant" - && !crate::text(&item).is_empty() + && let Some(rest) = crate::text(&item).strip_prefix(streamed.as_str()) + && !rest.is_empty() { out.update( id, json!({"sessionUpdate": "agent_message_chunk", "messageId": message_id(&bot, turn, state.part), - "content": {"type": "text", "text": crate::text(&item)}}), + "content": {"type": "text", "text": rest}}), ); } } "tool_started" => { - let name = data["name"].as_str().unwrap_or("tool").to_owned(); - let call = data["call_id"].as_str().unwrap_or("").to_owned(); - let arguments: Value = match data["arguments_truncated"] == true { - true => whole_arguments(&client, &bot, &call).await, - false => data["arguments"] - .as_str() - .and_then(|a| serde_json::from_str(a).ok()) - .unwrap_or(Value::Null), - }; + let (call, name, arguments) = started_call(&client, &bot, data).await; state.part += 1; - state.streamed = false; + state.streamed.clear(); if name == reactions::TOOL { return; } @@ -840,6 +875,20 @@ impl Session { self.approvals(agent, &client, &bot, turn).await; } "turn_finished" => { + // A steer the running turn took in: the prompt waits on that + // turn now. + if data["status"] == "steered" { + let into = data["into"].as_i64(); + if let (Some(steer), Some(into)) = (turn, into) { + state.steered = Some((steer, into)); + } + if let Some((_, at)) = &mut state.prompt + && *at == turn + { + *at = into; + } + return; + } if state.running.is_some() && state.running != turn { return; } @@ -859,7 +908,9 @@ impl Session { if agent.v2() { out.update(id, ended); } - let ours = matches!(&state.prompt, Some((_, at)) if at.is_none() || *at == turn); + // A prompt whose turn is not named yet waits: the turn that + // ended may be one it was sent behind. + let ours = matches!(&state.prompt, Some((_, at)) if at.is_some() && *at == turn); if ours && let Some((prompt, _)) = state.prompt.take() { let mut result = json!({"stopReason": reason}); if failed { @@ -888,13 +939,29 @@ impl Session { bot: &str, turn: Option, ) { - let Ok(page) = client - .request("approvals", json!({"bot": bot, "limit": 64})) - .await - else { - return; - }; - for call in page["approvals"].as_array().into_iter().flatten() { + // Every page: a round's calls are listed together, and the later + // ones are not announced again. + let mut listed = Vec::new(); + let mut after = 0; + loop { + let Ok(mut page) = client + .request( + "approvals", + json!({"bot": bot, "after": after, "limit": 64}), + ) + .await + else { + break; + }; + if let Value::Array(calls) = page["approvals"].take() { + listed.extend(calls); + } + match page["next_after"].as_i64() { + Some(next) => after = next, + None => break, + } + } + for call in &listed { let person = !call["asked"].is_null() || (call["gates"].as_array()) .is_some_and(|gates| gates.iter().any(|g| g == "manual")); @@ -968,13 +1035,34 @@ async fn ask(agent: &Agent, session: &str, client: &Client, bot: &str, call: &Va Some(outcome) if outcome["outcome"] == "selected" => "deny", _ => return, }; - let _ = client - .request( - "answer", - json!({"bot": bot, "turn": call["turn"], "call_id": id, "request": call["request"], - "tag": crate::person_gate(call), "decision": decision, "by": "acp"}), - ) - .await; + // Through the thread's connection as it is now: the one the call was + // listed on may have dropped while the person chose. + let Some(session) = agent + .sessions + .lock() + .ok() + .and_then(|s| s.get(session).cloned()) + else { + return; + }; + let client = (session.state.lock().await.bound.as_ref()).map(|b| b.client.clone()); + let sent = match client { + Some(client) => client + .request( + "answer", + json!({"bot": bot, "turn": call["turn"], "call_id": id, "request": call["request"], + "tag": crate::person_gate(call), "decision": decision, "by": "acp"}), + ) + .await + .is_ok(), + None => false, + }; + // Not delivered: the call is put to the person again once the thread + // follows its bot again. + if !sent { + let key = format!("{id}#{}", call["request"]); + session.state.lock().await.asked.remove(&key); + } } /// Handle the events a follow replays, until it is live: the cursor it is @@ -1303,6 +1391,21 @@ async fn items(client: &Client, bot: &str, nodes: &[i64]) -> Vec { } } +/// A started call's id, name and arguments, read whole when its event +/// carried only their start. +async fn started_call(client: &Client, bot: &str, data: &Value) -> (String, String, Value) { + let name = data["name"].as_str().unwrap_or("tool").to_owned(); + let call = data["call_id"].as_str().unwrap_or("").to_owned(); + let arguments = match data["arguments_truncated"] == true { + true => whole_arguments(client, bot, &call).await, + false => data["arguments"] + .as_str() + .and_then(|a| serde_json::from_str(a).ok()) + .unwrap_or(Value::Null), + }; + (call, name, arguments) +} + /// Call `id`'s whole arguments, from the bot's newest items, for one whose /// event carried only their start. async fn whole_arguments(client: &Client, bot: &str, id: &str) -> Value { diff --git a/tests/test_acp.py b/tests/test_acp.py index 7ef40321b..ac05bf4ec 100644 --- a/tests/test_acp.py +++ b/tests/test_acp.py @@ -210,6 +210,29 @@ def test_a_thread_is_its_folders_lead_and_a_task_it_starts_opens_as_a_thread_of_ self.assertEqual([b['name'] for b in self.daemon.request('bots')['result']['bots']], ['calc.fix', 'calc.lead']) + def test_a_prompt_that_steers_is_answered_when_the_turn_it_joined_ends(self): + repo = self.repo() + self.daemon.request('create', bot='calc.lead', workspace=str(repo), model='openai/synthetic-model', + instructions='Lead.', declare=[declared('start_task', inherit=False)], + approve=['start_task'], approver='manual') + thread, session = self.open(repo) + self.model.call_script = [('start_task', {'name': 'fix', 'brief': 'Fix calc.py.'})] + first = thread.ask('session/prompt', {'sessionId': session, 'prompt': self.wrapped('script')}) + [asked] = [m for m in thread.until(lambda m: m.get('method') == 'session/request_permission') + if m.get('method') == 'session/request_permission'] + # A second prompt while the turn runs steers it: the first prompt's + # words are in, and the second waits on the turn that takes it in. + second = thread.ask('session/prompt', {'sessionId': session, 'prompt': self.wrapped('and the tests')}) + answered, _ = thread.answer_of(first) + self.assertEqual(answered['result'], {'stopReason': 'end_turn'}) + thread.send({'id': asked['id'], 'result': {'outcome': {'outcome': 'selected', 'optionId': 'allow'}}}) + answered, updates = thread.answer_of(second) + self.assertEqual(answered['result'], {'stopReason': 'end_turn'}) + self.assertEqual(updates[-1]['state'], 'idle') + turns = self.daemon.request('turns', bot='calc.lead')['result']['turns'] + self.assertEqual(sorted(t['status'] for t in turns), ['completed', 'steered']) + self.assertEqual(self.t3.calls.get(timeout=30)['name'], 't3_thread_launch') + def test_a_turn_the_lead_takes_on_its_own_shows_in_the_thread(self): repo = self.repo() self.daemon.request('create', bot='calc.lead', workspace=str(repo), model='openai/synthetic-model', From 0955f7b20b4607f5677df5d6b8c65f8ee8302884 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 02:07:59 +0000 Subject: [PATCH 09/17] sonny acp: a reasoning item leaves the streamed reply as it is The streamed prefix is settled only by the assistant's words, so a model that reasons before it answers no longer has its reply said twice. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFMvxuKK3qzEb4j48d --- sonny/src/acp.rs | 17 +++++++++++++---- tests/test_acp.py | 11 +++++++++-- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/sonny/src/acp.rs b/sonny/src/acp.rs index a7c99d042..437348917 100644 --- a/sonny/src/acp.rs +++ b/sonny/src/acp.rs @@ -790,11 +790,20 @@ impl Session { // Words that did not stream, as from a thread that came late or // whose stream dropped partway: what was not sent yet. "message" => { + // Only the assistant's words settle what streamed: a + // reasoning item before them leaves it as it is. + let Some(node) = data["node"].as_i64() else { + return; + }; + let Some(item) = items(&client, &bot, &[node]).await.into_iter().next() else { + return; + }; + let text = crate::text(&item); + if item["role"] != "assistant" || text.is_empty() { + return; + } let streamed = std::mem::take(&mut state.streamed); - if let Some(node) = data["node"].as_i64() - && let Some(item) = items(&client, &bot, &[node]).await.into_iter().next() - && item["role"] == "assistant" - && let Some(rest) = crate::text(&item).strip_prefix(streamed.as_str()) + if let Some(rest) = text.strip_prefix(streamed.as_str()) && !rest.is_empty() { out.update( diff --git a/tests/test_acp.py b/tests/test_acp.py index ac05bf4ec..4da5482f0 100644 --- a/tests/test_acp.py +++ b/tests/test_acp.py @@ -238,9 +238,16 @@ def test_a_turn_the_lead_takes_on_its_own_shows_in_the_thread(self): self.daemon.request('create', bot='calc.lead', workspace=str(repo), model='openai/synthetic-model', instructions='Lead.') thread, session = self.open(repo) - answered, _ = thread.answer_of(thread.ask('session/prompt', {'sessionId': session, - 'prompt': self.wrapped('hello')})) + # A model that reasons before it answers: the reply, which + # streamed, is said once. + self.model.history_reasoning = {'type': 'reasoning', 'id': 'rs_1', + 'summary': [{'type': 'summary_text', 'text': 'Thought.'}]} + answered, updates = thread.answer_of(thread.ask('session/prompt', {'sessionId': session, + 'prompt': self.wrapped('hello')})) self.assertEqual(answered['result'], {'stopReason': 'end_turn'}) + self.assertEqual(''.join(u['content']['text'] for u in updates + if u['sessionUpdate'] == 'agent_message_chunk'), 'reply:hello') + self.model.history_reasoning = None # The lookout wakes the lead, as when a task finished: no prompt of # the thread's, so the thread is told what started it. self.daemon.request('submit', bot='calc.lead', request_id='lookout-1', prompt='fix finished') From bfcafee19dfdf6c290de0584c98813010df1b347 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 02:13:57 +0000 Subject: [PATCH 10/17] sonny acp: a task thread that came as its turn ended answers once The claimed prompt is answered here only while it is still this thread's and waits on no turn; one the follower already answered is left alone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFMvxuKK3qzEb4j48d --- sonny/src/acp.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/sonny/src/acp.rs b/sonny/src/acp.rs index 437348917..466345880 100644 --- a/sonny/src/acp.rs +++ b/sonny/src/acp.rs @@ -497,12 +497,18 @@ impl Session { } if task { let mut state = self.state.lock().await; - // Its turn ended before the thread came: nothing to wait for. - if state.running.is_none() { - state.prompt = None; - drop(state); - self.say_last(agent).await; - return Ok(json!({"stopReason": "end_turn"})); + match &state.prompt { + // Its turn ended before the thread came: nothing to + // wait for. + Some((prompt, None)) if prompt == id => { + state.prompt = None; + drop(state); + self.say_last(agent).await; + return Ok(json!({"stopReason": "end_turn"})); + } + Some((prompt, Some(_))) if prompt == id => {} + // It ended since, and the follower answered it. + _ => return Ok(Value::Null), } if agent.v2() { agent.out.update( From 530dfa4789cf5f39c3633ed9feadb3a975f801c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 03:31:32 +0000 Subject: [PATCH 11/17] sonny acp: read the person's words out of each block T3 Code sends T3 Code sends every prompt as its instructions around the person's words, then a note of its own (, ) as a second text block. Joined, the prompt no longer ended in , so nothing was unwrapped: a task's thread could not claim its task (its brief no longer matched) and opened a lead for the worktree instead, failing with project_exists; leads read T3 Code's instructions for tools they do not have. Each block is now unwrapped on its own and T3 Code's note is left out. The runtime tests send T3 Code's real two-block shape. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFMvxuKK3qzEb4j48d --- sonny/src/acp.rs | 30 ++++++++++++++++++++++++++++-- sonny/src/t3.rs | 7 +++++++ tests/test_acp.py | 7 +++++-- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/sonny/src/acp.rs b/sonny/src/acp.rs index 466345880..a18a0d759 100644 --- a/sonny/src/acp.rs +++ b/sonny/src/acp.rs @@ -476,7 +476,6 @@ impl Session { async fn prompt(self: &Arc, agent: &Arc, id: &Value, params: &Value) -> Reply { let (text, images) = words(¶ms["prompt"]); - let text = t3::request(&text).to_owned(); let bound = { let mut state = self.state.lock().await; state.cancelled = false; @@ -1306,7 +1305,14 @@ fn words(prompt: &Value) -> (String, Vec) { let mut images = Vec::new(); for block in prompt.as_array().into_iter().flatten() { match block["type"].as_str().unwrap_or("") { - "text" => text.push_str(block["text"].as_str().unwrap_or("")), + // Each block as T3 Code sends it: its wrapper around the person's + // words, and its note after them, are its own. + "text" => { + let block = block["text"].as_str().unwrap_or(""); + if !t3::is_note(block) { + text.push_str(t3::request(block)); + } + } "image" => images.push(json!({"media_type": block["mimeType"], "data": block["data"]})), "resource_link" => { let uri = block["uri"].as_str().unwrap_or(""); @@ -1464,6 +1470,26 @@ mod tests { ); } + #[test] + fn a_prompt_from_t3_code_is_the_persons_words() { + // As T3 Code sends every prompt: its instructions around the + // person's words, then its note as a block of its own. + let prompt = json!([ + {"type": "text", "text": "\nUse delegate_task.\n\n\n\nFix the cart.\n"}, + {"type": "image", "mimeType": "image/png", "data": "AAAA"}, + {"type": "text", "text": "In case you're asked: you are running in T3 Code.\n\nLink PRs."} + ]); + let (text, images) = words(&prompt); + assert_eq!(text, "Fix the cart."); + assert_eq!(images.len(), 1); + // A later prompt carries no instructions, only the note. + let prompt = json!([ + {"type": "text", "text": "And the tests."}, + {"type": "text", "text": "In case you're asked."} + ]); + assert_eq!(words(&prompt).0, "And the tests."); + } + #[test] fn a_plan_is_the_protocols_plan() { let entries = diff --git a/sonny/src/t3.rs b/sonny/src/t3.rs index 22e02f544..dfa7641c2 100644 --- a/sonny/src/t3.rs +++ b/sonny/src/t3.rs @@ -177,6 +177,13 @@ fn result(tool: &str, called: Value) -> Result { Ok(serde_json::from_str(&text).unwrap_or(Value::String(text))) } +/// The note T3 Code adds to every prompt as a block of its own, after the +/// person's words: which harness runs the thread, and how it links pull +/// requests. It is T3 Code's, not the person's. +pub fn is_note(block: &str) -> bool { + block.starts_with("") +} + /// What the person wrote, out of a prompt T3 Code wrapped in its own /// instructions: those name tools the bot does not have. pub fn request(prompt: &str) -> &str { diff --git a/tests/test_acp.py b/tests/test_acp.py index 4da5482f0..defd6a957 100644 --- a/tests/test_acp.py +++ b/tests/test_acp.py @@ -148,9 +148,12 @@ def open(self, cwd): @staticmethod def wrapped(text): - """A prompt as T3 Code sends it, in its own instructions.""" + """A prompt as T3 Code sends it: in its own instructions, then its + note as a block of its own.""" return [{'type': 'text', 'text': '\nUse delegate_task.\n\n\n' - f'\n{text}\n'}] + f'\n{text}\n'}, + {'type': 'text', 'text': "In case you're asked: you are running in T3 Code." + '\n\nLink pull requests.'}] def test_a_thread_is_its_folders_lead_and_a_task_it_starts_opens_as_a_thread_of_its_own(self): repo = self.repo() From a61b89e954340475203590066b85666fb29e7066 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 03:43:52 +0000 Subject: [PATCH 12/17] sonny acp: T3 Code's model picker lists your models and switches the lead session/new and session/resume return a model option (category model) built from ~/.agent/models, with Default first. A pick before the first prompt is the model a new lead starts on; a later pick configures the bot when it is idle, or before its next turn. A bot's thread offers only its provider family, refuses another with -32602, and shows the bot's model again through config_option_update when another client changes it. A task's thread keeps the model its lead gave the task. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFMvxuKK3qzEb4j48d --- sonny/README.md | 7 +- sonny/src/acp.rs | 203 +++++++++++++++++++++++++++++++++++++++++++++- tests/test_acp.py | 38 +++++++++ 3 files changed, 244 insertions(+), 4 deletions(-) diff --git a/sonny/README.md b/sonny/README.md index 648cd8101..ec929d42f 100644 --- a/sonny/README.md +++ b/sonny/README.md @@ -201,7 +201,12 @@ inherits. Sonny takes the person's words out of the instructions T3 Code wraps a prompt in, which name tools Sonny's agents do not have, so T3 Code's Plan mode does not reach them; plan with the lead instead. What thread is which bot is kept in `~/.agent/sonny/t3`, so a thread T3 Code resumes is the -same bot. +same bot. T3 Code's model picker lists `~/.agent/models` (the list `agent +models` shows) after "Default", the model the lead or task runs: a model +picked before the first prompt is the one a new lead starts on, and one +picked later is the lead's from its next turn. A thread offers only its +bot's provider family, since a bot changes model only within it, and a task's +thread keeps the model its lead gave the task. Enter sends (and steers a running turn), Esc interrupts, `y`/`n` answer a pending approval, Ctrl-C clears the composer or quits. Shift-Enter or diff --git a/sonny/src/acp.rs b/sonny/src/acp.rs index a18a0d759..45ea5bdcb 100644 --- a/sonny/src/acp.rs +++ b/sonny/src/acp.rs @@ -34,6 +34,10 @@ const INVALID_PARAMS: i64 = -32602; const NOT_FOUND: i64 = -32601; const FAILED: i64 = -32603; +/// The model picker's first choice: the model the thread's bot runs, or +/// the one Sonny gives a new lead. +const DEFAULT_MODEL: &str = "default"; + /// `sonny acp [--store PATH] [--socket PATH]`. pub fn run(args: &[String]) -> Result<(), String> { let (mut store, mut socket) = (None, None); @@ -196,6 +200,10 @@ impl Agent { Some(session) => session.prompt(&self, id, params).await, None => Err((INVALID_PARAMS, "no such session".into())), }, + "session/set_config_option" => match self.session(params) { + Some(session) => session.set_option(&self, params).await, + None => Err((INVALID_PARAMS, "no such session".into())), + }, "session/close" => { let closed = params["sessionId"].as_str().and_then(|session| { self.sessions @@ -252,6 +260,38 @@ impl Agent { self.version.load(Ordering::Relaxed) >= 2 } + /// A thread's options: its model, picked from `~/.agent/models` as + /// `agent models` lists it, with Sonny's own choice first. A bot's + /// thread offers only its family's, since a bot changes model only + /// within it; `bound` is the model the bot runs and the daemon's + /// providers. + fn options(&self, value: &str, bound: Option<(&str, &Value)>) -> Value { + let listed = agent_client::models::path() + .and_then(|path| agent_client::models::read(&path).ok()) + .unwrap_or_default(); + let mut choices = vec![json!({"value": DEFAULT_MODEL, "name": "Default", + "description": "The model the lead or task runs, or the one Sonny gives a new lead"})]; + for model in listed { + if let Some((current, providers)) = bound + && family(providers, &model.id) != family(providers, current) + { + continue; + } + let mut choice = json!({"value": model.id, "name": model.id}); + if let Some(note) = model.note { + choice["description"] = json!(note); + } + choices.push(choice); + } + if !choices.iter().any(|choice| choice["value"] == value) { + choices.push(json!({"value": value, "name": value})); + } + let mut option = json!({"name": "Model", "category": "model", "type": "select", + "currentValue": value, "options": choices}); + option[if self.v2() { "configId" } else { "id" }] = json!("model"); + json!([option]) + } + /// The daemon, started as `sonny` starts it, with Sonny's skills /// installed before any lead is made, so its instructions list them. async fn ready(&self) -> Result<(), String> { @@ -308,9 +348,10 @@ impl Agent { } return Err((FAILED, error)); } + let options = session.options(self).await; Ok(match resumed { - Some(_) => json!({}), - None => json!({"sessionId": id}), + Some(_) => json!({"configOptions": options}), + None => json!({"sessionId": id, "configOptions": options}), }) } } @@ -359,6 +400,9 @@ struct State { /// The last steer a running turn took in, and that turn: for a prompt /// whose steer went in before its turn was named. steered: Option<(i64, i64)>, + /// The model the person picked, which the bot takes when it is next + /// idle; a new lead starts on it. + picked: Option, } struct Bound { @@ -369,6 +413,9 @@ struct Bound { workspace: String, /// The folder a moved lead comes back to with its next turn. moving: Option, + /// `provider/model`, as the daemon names it, and its effort. + model: String, + effort: Option, } impl Session { @@ -391,7 +438,8 @@ impl Session { (bot, project, None) } None => { - let lead = project::open(&client, &self.cwd, None).await?; + let picked = self.state.lock().await.picked.clone(); + let lead = project::open(&client, &self.cwd, picked.as_deref()).await?; let moving = lead.moving.as_ref().map(|dir| dir.display().to_string()); setup_lead(agent, &self.id, &client, &lead, moving.as_deref()).await?; (lead.bot, Some(lead.project), moving) @@ -424,6 +472,12 @@ impl Session { .map_err(|e| e.to_string())?; let workspace = record["workspace"].as_str().unwrap_or_default().to_owned(); let running = record["running_turn"].as_i64(); + let model = format!( + "{}/{}", + record["provider"].as_str().unwrap_or(""), + record["model"].as_str().unwrap_or("") + ); + let effort = record["effort"].as_str().map(str::to_owned); let mut calls = HashMap::new(); for (turn, data) in started.into_values() { if running.is_some() && turn == running { @@ -439,12 +493,19 @@ impl Session { state.running = running; state.calls = calls; state.cursor = cursor; + // A task keeps the model its lead gave it, whatever T3 Code + // picked for the thread it opened. + if claimed || state.picked.as_ref() == Some(&model) { + state.picked = None; + } state.bound = Some(Bound { bot, client, project, workspace, moving, + model, + effort, }); // The thread T3 Code opened for a task stands for its first // turn, which runs already. @@ -452,6 +513,7 @@ impl Session { state.prompt = state.prompt.take().map(|(id, _)| (id, Some(turn))); } } + self.show_options(agent).await; let (session, agent) = (self.clone(), agent.clone()); // Calls already waiting for the person, whose events were before // this thread came, are put to it too. @@ -474,6 +536,109 @@ impl Session { Ok(()) } + /// The thread's options as they are now. + async fn options(&self, agent: &Agent) -> Value { + let state = self.state.lock().await; + let bound = state.bound.as_ref(); + let value = (state.picked.as_deref()) + .or(bound.map(|bound| bound.model.as_str())) + .unwrap_or(DEFAULT_MODEL); + agent.options( + value, + bound.map(|bound| (bound.model.as_str(), bound.client.providers())), + ) + } + + async fn show_options(&self, agent: &Agent) { + let options = self.options(agent).await; + agent.out.update( + &self.id, + json!({"sessionUpdate": "config_option_update", "configOptions": options}), + ); + } + + /// The person picks a model. A bot's thread takes one of its family + /// only, at once when the bot is idle and with its next turn when it + /// is not; a thread that is no bot yet keeps it for the lead it makes. + async fn set_option(&self, agent: &Agent, params: &Value) -> Reply { + if params["configId"] != "model" { + return Err(( + INVALID_PARAMS, + format!("Sonny has no option {}", params["configId"]), + )); + } + let Some(value) = params["value"].as_str() else { + return Err((INVALID_PARAMS, "a model is picked by its id".into())); + }; + { + let mut state = self.state.lock().await; + let picked = match &state.bound { + Some(bound) if value != DEFAULT_MODEL && value != bound.model => { + let providers = bound.client.providers(); + let wanted = family(providers, value); + if wanted.is_null() { + return Err((INVALID_PARAMS, format!("no provider serves {value}"))); + } + if wanted != family(providers, &bound.model) { + return Err(( + INVALID_PARAMS, + format!( + "{} stays in {}'s family: its history is in that family's shape", + bound.bot, bound.model + ), + )); + } + Some(value.to_owned()) + } + Some(_) => None, + None => (value != DEFAULT_MODEL).then(|| value.to_owned()), + }; + state.picked = picked; + } + self.switch(agent) + .await + .map_err(|error| (INVALID_PARAMS, error))?; + Ok(json!({"configOptions": self.options(agent).await})) + } + + /// Give an idle bot the model the person picked. + async fn switch(&self, agent: &Agent) -> Result<(), String> { + let (client, asked, model) = { + let state = self.state.lock().await; + let (Some(bound), Some(model), None) = (&state.bound, &state.picked, state.running) + else { + return Ok(()); + }; + let asked = json!({"bot": bound.bot, "model": model, "effort": bound.effort}); + (bound.client.clone(), asked, model.clone()) + }; + let answer = client.request("configure", asked).await; + let mut state = self.state.lock().await; + // Picked again meanwhile: that pick waits its turn. + if state.picked.as_ref() != Some(&model) { + return Ok(()); + } + match answer { + Ok(_) => { + state.picked = None; + if let Some(bound) = &mut state.bound { + bound.model = model; + } + drop(state); + self.show_options(agent).await; + Ok(()) + } + // It took a turn meanwhile: the pick waits for the next. + Err(error) if error.code == "bot_busy" => Ok(()), + Err(error) => { + state.picked = None; + drop(state); + self.show_options(agent).await; + Err(error.to_string()) + } + } + } + async fn prompt(self: &Arc, agent: &Arc, id: &Value, params: &Value) -> Reply { let (text, images) = words(¶ms["prompt"]); let bound = { @@ -518,6 +683,15 @@ impl Session { return Ok(Value::Null); } } + // A model picked while the bot worked, or before a lead that was + // there already: the bot takes it before this turn. + if let Err(error) = self.switch(agent).await { + agent.out.update( + &self.id, + json!({"sessionUpdate": "notice", "severity": "warning", + "title": "Sonny kept the model", "description": error}), + ); + } let mut state = self.state.lock().await; if std::mem::take(&mut state.cancelled) { return Ok(json!({"stopReason": "cancelled"})); @@ -748,6 +922,20 @@ impl Session { state.calls.clear(); } state.running = turn; + // What the turn runs on: another client may have picked + // another model for the bot. + if let (Some(bound), Some(model)) = (&mut state.bound, data["model"].as_str()) + && model != bound.model + { + bound.model = model.to_owned(); + bound.effort = data["effort"].as_str().map(str::to_owned); + let value = state.picked.as_deref().unwrap_or(model); + let options = agent.options(value, Some((model, client.providers()))); + out.update( + id, + json!({"sessionUpdate": "config_option_update", "configOptions": options}), + ); + } let ours = match &mut state.prompt { Some((_, at @ None)) => { *at = turn; @@ -1382,6 +1570,15 @@ fn text_content(text: &str) -> Value { } /// At most `OUTPUT_BYTES` of a call's output, on a character boundary. +/// The family a `provider/model` belongs to, as the daemon's providers +/// say: null for a provider it does not serve. +fn family<'a>(providers: &'a Value, model: &str) -> &'a Value { + let provider = model + .split_once('/') + .map_or(model, |(provider, _)| provider); + &providers[provider]["family"] +} + fn cut(text: &str) -> String { if text.len() <= OUTPUT_BYTES { return text.to_owned(); diff --git a/tests/test_acp.py b/tests/test_acp.py index defd6a957..ecd0d69ff 100644 --- a/tests/test_acp.py +++ b/tests/test_acp.py @@ -144,6 +144,7 @@ def open(self, cwd): self.assertEqual(initialized['result']['protocolVersion'], 2) self.assertEqual(initialized['result']['agentInfo']['name'], 'sonny') opened, _ = thread.answer_of(thread.ask('session/new', {'cwd': str(cwd), 'mcpServers': []})) + self.opened = opened return thread, opened['result']['sessionId'] @staticmethod @@ -261,6 +262,43 @@ def test_a_turn_the_lead_takes_on_its_own_shows_in_the_thread(self): self.assertEqual(said['content'], [{'type': 'text', 'text': 'fix finished'}]) self.assertTrue([u for u in updates if u['sessionUpdate'] == 'agent_message_chunk']) + def test_the_model_picked_in_the_thread_is_the_leads(self): + repo = self.repo() + (self.path / '.agent').mkdir(exist_ok=True) + (self.path / '.agent' / 'models').write_text( + 'openai/synthetic-model\nopenai/other-model # faster\nanthropic/claude-x\n') + self.model.models = ('synthetic-model', 'other-model') + thread, session = self.open(repo) + [option] = self.opened['result']['configOptions'] + self.assertEqual((option['configId'], option['category'], option['currentValue']), + ('model', 'model', 'default')) + self.assertEqual([c['value'] for c in option['options']], + ['default', 'openai/synthetic-model', 'openai/other-model', 'anthropic/claude-x']) + self.assertEqual(option['options'][2]['description'], 'faster') + # Picked before there is a lead: the lead Sonny makes runs it. + picked, _ = thread.answer_of(thread.ask('session/set_config_option', { + 'sessionId': session, 'configId': 'model', 'type': 'id', 'value': 'openai/other-model'})) + self.assertEqual(picked['result']['configOptions'][0]['currentValue'], 'openai/other-model') + answered, updates = thread.answer_of(thread.ask('session/prompt', {'sessionId': session, + 'prompt': self.wrapped('hello')})) + self.assertEqual(answered['result'], {'stopReason': 'end_turn'}) + lead = self.daemon.request('inspect', bot='calc.lead')['result'] + self.assertEqual((lead['provider'], lead['model']), ('openai', 'other-model')) + # A lead's thread offers its family's models only. + [shown] = [u for u in updates if u['sessionUpdate'] == 'config_option_update'] + [option] = shown['configOptions'] + self.assertEqual(option['currentValue'], 'openai/other-model') + self.assertEqual([c['value'] for c in option['options']], + ['default', 'openai/synthetic-model', 'openai/other-model']) + refused, _ = thread.answer_of(thread.ask('session/set_config_option', { + 'sessionId': session, 'configId': 'model', 'type': 'id', 'value': 'anthropic/claude-x'})) + self.assertEqual(refused['error']['code'], -32602) + # Picked while the lead is idle: it runs it at once. + switched, updates = thread.answer_of(thread.ask('session/set_config_option', { + 'sessionId': session, 'configId': 'model', 'type': 'id', 'value': 'openai/synthetic-model'})) + self.assertEqual(switched['result']['configOptions'][0]['currentValue'], 'openai/synthetic-model') + self.assertEqual(self.daemon.request('inspect', bot='calc.lead')['result']['model'], 'synthetic-model') + def test_a_thread_sonny_does_not_know_does_not_come_back(self): repo = self.repo() thread = Thread(self, self.env, repo) From 073e0a953078aa783674326910ec9d36414d7703 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 03:51:52 +0000 Subject: [PATCH 13/17] sonny acp: one model change at a time, and a pick waits for the bot's next idle Model changes hold the session's switching lock, so overlapping picks cannot drop a configure that succeeded. A pick made while the bot works is applied when its turn finishes, not only before the person's next prompt, so the lookout's turns run it too. The thread refreshes the bot's model from inspect when a turn ends, rather than taking a turn's model, which may be that turn's own override. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFMvxuKK3qzEb4j48d --- sonny/src/acp.rs | 77 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 51 insertions(+), 26 deletions(-) diff --git a/sonny/src/acp.rs b/sonny/src/acp.rs index 45ea5bdcb..f315a8597 100644 --- a/sonny/src/acp.rs +++ b/sonny/src/acp.rs @@ -324,6 +324,7 @@ impl Agent { cwd, state: AsyncMutex::new(State::default()), follower: Mutex::new(None), + switching: AsyncMutex::new(()), }); // Open before it follows its bot; one it replaces stops following. let replaced = self @@ -366,6 +367,8 @@ struct Session { state: AsyncMutex, /// What follows its bot, stopped when the thread closes. follower: Mutex>, + /// Held while the bot's model changes: one change at a time. + switching: AsyncMutex<()>, } impl Session { @@ -570,6 +573,7 @@ impl Session { let Some(value) = params["value"].as_str() else { return Err((INVALID_PARAMS, "a model is picked by its id".into())); }; + let _one = self.switching.lock().await; { let mut state = self.state.lock().await; let picked = match &state.bound { @@ -601,7 +605,49 @@ impl Session { Ok(json!({"configOptions": self.options(agent).await})) } - /// Give an idle bot the model the person picked. + /// Once the bot is idle, as before a prompt's turn or after a turn: + /// the model the person picked meanwhile, and the model it runs, as + /// another client may have changed it. + async fn settle(&self, agent: &Agent) { + let _one = self.switching.lock().await; + if let Err(error) = self.switch(agent).await { + agent.out.update( + &self.id, + json!({"sessionUpdate": "notice", "severity": "warning", + "title": "Sonny kept the model", "description": error}), + ); + } + let Some((client, bot)) = (self.state.lock().await.bound.as_ref()) + .map(|bound| (bound.client.clone(), bound.bot.clone())) + else { + return; + }; + let Ok(record) = client.request("inspect", json!({"bot": bot})).await else { + return; + }; + let model = format!( + "{}/{}", + record["provider"].as_str().unwrap_or(""), + record["model"].as_str().unwrap_or("") + ); + let changed = { + let mut state = self.state.lock().await; + match &mut state.bound { + Some(bound) if bound.bot == bot && bound.model != model => { + bound.model = model; + bound.effort = record["effort"].as_str().map(str::to_owned); + true + } + _ => false, + } + }; + if changed { + self.show_options(agent).await; + } + } + + /// Give an idle bot the model the person picked. The caller holds + /// `switching`. async fn switch(&self, agent: &Agent) -> Result<(), String> { let (client, asked, model) = { let state = self.state.lock().await; @@ -614,10 +660,6 @@ impl Session { }; let answer = client.request("configure", asked).await; let mut state = self.state.lock().await; - // Picked again meanwhile: that pick waits its turn. - if state.picked.as_ref() != Some(&model) { - return Ok(()); - } match answer { Ok(_) => { state.picked = None; @@ -685,13 +727,7 @@ impl Session { } // A model picked while the bot worked, or before a lead that was // there already: the bot takes it before this turn. - if let Err(error) = self.switch(agent).await { - agent.out.update( - &self.id, - json!({"sessionUpdate": "notice", "severity": "warning", - "title": "Sonny kept the model", "description": error}), - ); - } + self.settle(agent).await; let mut state = self.state.lock().await; if std::mem::take(&mut state.cancelled) { return Ok(json!({"stopReason": "cancelled"})); @@ -814,6 +850,9 @@ impl Session { break; } self.on(agent, &event).await; + if event["event"] == "turn_finished" { + self.settle(agent).await; + } } let mut failed = false; events = loop { @@ -922,20 +961,6 @@ impl Session { state.calls.clear(); } state.running = turn; - // What the turn runs on: another client may have picked - // another model for the bot. - if let (Some(bound), Some(model)) = (&mut state.bound, data["model"].as_str()) - && model != bound.model - { - bound.model = model.to_owned(); - bound.effort = data["effort"].as_str().map(str::to_owned); - let value = state.picked.as_deref().unwrap_or(model); - let options = agent.options(value, Some((model, client.providers()))); - out.update( - id, - json!({"sessionUpdate": "config_option_update", "configOptions": options}), - ); - } let ours = match &mut state.prompt { Some((_, at @ None)) => { *at = turn; From 084c19a7018625f0b81c4ae024f53d5625a6cb7d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 04:57:57 +0000 Subject: [PATCH 14/17] sonny acp: a task opens as the lead thread's child in T3 Code While the person's prompt runs, the task a lead starts opens through T3 Code's delegate_task, as a child thread nested under the lead's, in the lead's folder (the task works in its own worktree). The prompt's answer waits until the child is open, so T3 Code's run is still on. A turn the lead takes on its own has no run to be a child of: delegate_task refuses, and the task opens with t3_thread_launch as before. T3 Code's message that a delegated task ended starts no turn; the lookout tells the lead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFMvxuKK3qzEb4j48d --- sonny/README.md | 19 +++++++----- sonny/src/acp.rs | 74 ++++++++++++++++++++++++++++++++++++++--------- sonny/src/t3.rs | 24 +++++++++++++-- tests/test_acp.py | 47 ++++++++++++++++++++---------- 4 files changed, 127 insertions(+), 37 deletions(-) diff --git a/sonny/README.md b/sonny/README.md index 8f6a74468..16300b8c9 100644 --- a/sonny/README.md +++ b/sonny/README.md @@ -229,13 +229,18 @@ waits for you is asked in the thread (Allow or Deny); one the judge passes runs without asking. The turns the lead takes on its own, as when the lookout wakes it because a task finished, show in the thread between yours, as work the agent started (the protocol's version 2 draft, which T3 Code speaks). -Each task the lead starts opens as a T3 Code thread of its own, in the task's -worktree (in the project's folder when T3 Code does not take the worktree as -one of the project's), with the task's brief as its first message; that -thread is the task's, so it shows the task's work and steers it. Sonny opens -it with the `t3_thread_launch` tool T3 Code gives each thread it runs, by the -credential T3 Code scopes to that thread, which no program Sonny starts -inherits. Sonny takes the person's words out of the instructions T3 Code +Each task the lead starts opens as a T3 Code thread with the task's brief as +its first message; that thread is the task's, so it shows the task's work and +steers it. A task started while your prompt runs is the lead thread's child, +nested under it, opened with T3 Code's `delegate_task` (T3 Code opens it in +the lead's folder; the task still works in its own worktree), and your +prompt's answer waits until it is open. T3 Code's message that a child ended +starts no turn: the lead hears of its tasks from the lookout. A task started +in a turn the lead took on its own, which T3 Code takes no child of, opens as +a thread of its own with `t3_thread_launch`, in the task's worktree (in the +project's folder when T3 Code does not take the worktree as one of the +project's). Sonny calls these tools by the credential T3 Code scopes to the +lead's thread, which no program Sonny starts inherits. Sonny takes the person's words out of the instructions T3 Code wraps a prompt in, which name tools Sonny's agents do not have, so T3 Code's Plan mode does not reach them; plan with the lead instead. What thread is which bot is kept in `~/.agent/sonny/t3`, so a thread T3 Code resumes is the diff --git a/sonny/src/acp.rs b/sonny/src/acp.rs index f315a8597..62f5d5e7f 100644 --- a/sonny/src/acp.rs +++ b/sonny/src/acp.rs @@ -3,8 +3,8 @@ //! thread. A thread in a folder is that folder's lead, as `sonny` there //! finds or makes it, set up the same way: its rules and skills, its tools, //! its approval mode, and the lookout. Each task the lead starts opens as a -//! thread of its own, through T3 Code's tools, and that thread is the -//! task's. +//! thread through T3 Code's tools, the lead thread's child while the +//! person's prompt runs, and that thread is the task's. //! //! It speaks the protocol's version 2 draft as T3 Code does: the turns a //! bot takes on its own, as when the lookout wakes a lead because a task @@ -406,6 +406,10 @@ struct State { /// The model the person picked, which the bot takes when it is next /// idle; a new lead starts on it. picked: Option, + /// Tasks whose threads are opening, as children of the person's prompt + /// while it runs, and that prompt's answer, held until they are open. + opening: usize, + held: Option<(Value, Value)>, } struct Bound { @@ -683,6 +687,9 @@ impl Session { async fn prompt(self: &Arc, agent: &Arc, id: &Value, params: &Value) -> Reply { let (text, images) = words(¶ms["prompt"]); + if agent.t3.is_some() && images.is_empty() && t3::is_completion(&text) { + return Ok(json!({"stopReason": "end_turn"})); + } let bound = { let mut state = self.state.lock().await; state.cancelled = false; @@ -1088,13 +1095,27 @@ impl Session { .as_ref() .map(|b| b.workspace.clone()) .unwrap_or_default(); - let (agent, session) = (agent.clone(), self.id.clone()); - tokio::spawn(async move { - open_task( - &agent, &session, &client, &project, &arguments, &workspace, &call, - ) - .await - }); + let this = (agent.sessions.lock().ok()) + .and_then(|sessions| sessions.get(&self.id).cloned()); + if let Some(session) = this { + state.opening += 1; + let agent = agent.clone(); + tokio::spawn(async move { + let at = Where { + project: &project, + workspace: &workspace, + thread: &session.cwd, + }; + open_task(&agent, &session.id, &client, &at, &arguments, &call).await; + let mut state = session.state.lock().await; + state.opening -= 1; + if state.opening == 0 + && let Some((prompt, result)) = state.held.take() + { + agent.out.answer(&prompt, result); + } + }); + } } } "approval_requested" | "approval_asked" => { @@ -1144,7 +1165,13 @@ impl Session { result["error"] = json!({"code": FAILED, "message": data["error"].as_str().unwrap_or("the turn failed")}); } - out.answer(&prompt, result); + // A task T3 Code opens as a child of this prompt opens + // while its run is on. + if state.opening > 0 { + state.held = Some((prompt, result)); + } else { + out.answer(&prompt, result); + } } } "deleted" => { @@ -1430,18 +1457,28 @@ async fn setup_lead( Ok(()) } +/// Where a lead's thread is: its project, the lead's workspace, and the +/// folder T3 Code opened the thread in. +struct Where<'a> { + project: &'a str, + workspace: &'a str, + thread: &'a Path, +} + /// Open the task a lead's `start_task` call started as a thread of its /// own, with the task's brief as its first message; the thread is the -/// task's when that message reaches it. The lead's call says where. +/// task's when that message reaches it. While the person's prompt runs it +/// is the lead thread's child, which T3 Code opens in the lead's folder; +/// otherwise a thread of its own, where the lead's call says. async fn open_task( agent: &Agent, session: &str, client: &Client, - project: &str, + at: &Where<'_>, arguments: &Value, - lead_workspace: &str, call: &str, ) { + let (project, lead_workspace) = (at.project, at.workspace); let Some(t3) = &agent.t3 else { return }; let Some(name) = arguments["name"].as_str() else { return; @@ -1472,6 +1509,17 @@ async fn open_task( .ok_or("the task has no brief")? .to_owned(); let workspace = record["workspace"].as_str().unwrap_or_default(); + let message = t3::expect(&bot, &brief, at.thread)?; + let child = json!({"task": message, "title": name, "clientRequestId": bot}); + // The person's prompt waits for it: not for ever. One that is late + // may open yet, so its task still waits for it and no other thread + // is opened for it. + let delegating = t3.call("delegate_task", child); + match tokio::time::timeout(std::time::Duration::from_secs(30), delegating).await { + Ok(Ok(delegated)) => return Ok(delegated), + Ok(Err(_)) => {} + Err(_) => return Ok(Value::Null), + } let message = t3::expect(&bot, &brief, Path::new(workspace))?; let mut launch = json!({"title": name, "message": message, "clientRequestId": bot}); if workspace != lead_workspace { diff --git a/sonny/src/t3.rs b/sonny/src/t3.rs index dfa7641c2..d9becaaa1 100644 --- a/sonny/src/t3.rs +++ b/sonny/src/t3.rs @@ -1,7 +1,7 @@ //! T3 Code, as `sonny acp` meets it: the tools T3 Code gives the agents it //! runs, over an MCP endpoint it names in their environment, with which a -//! lead's thread opens a thread for each task the lead starts; the tasks -//! waiting for their thread, and the bot each thread is. +//! lead's thread opens a child thread for each task the lead starts; the +//! tasks waiting for their thread, and the bot each thread is. use serde_json::{Value, json}; use std::path::{Path, PathBuf}; @@ -184,6 +184,15 @@ pub fn is_note(block: &str) -> bool { block.starts_with("") } +/// The message T3 Code sends a thread when a task it delegated ends, +/// which asks it to read the task's result with T3 Code's tools: a lead +/// hears of its tasks from the lookout instead. +pub fn is_completion(prompt: &str) -> bool { + let prompt = prompt.trim(); + (prompt.starts_with("Delegated task ") && prompt.contains(" reached a terminal state.")) + || (prompt.starts_with("Delegated tasks ") && prompt.contains(" reached terminal states.")) +} + /// What the person wrote, out of a prompt T3 Code wrapped in its own /// instructions: those name tools the bot does not have. pub fn request(prompt: &str) -> &str { @@ -330,6 +339,17 @@ mod tests { assert_eq!(request("Fix it."), "Fix it."); } + #[test] + fn a_delegated_tasks_end_is_t3_codes_message() { + assert!(is_completion( + "Delegated task node-1 reached a terminal state. Use task_status with taskId node-1 to read the result." + )); + assert!(is_completion( + "Delegated tasks node-1, node-2 reached terminal states. Use task_status with each taskId to read the results." + )); + assert!(!is_completion("Delegated task review: is it done?")); + } + #[test] fn a_thread_claims_the_task_waiting_for_its_message_in_its_folder() { let home = std::env::temp_dir().join(format!("sonny-t3-{}", std::process::id())); diff --git a/tests/test_acp.py b/tests/test_acp.py index ecd0d69ff..c03e37955 100644 --- a/tests/test_acp.py +++ b/tests/test_acp.py @@ -18,7 +18,9 @@ class T3Tools(http.server.BaseHTTPRequestHandler): - """T3 Code's MCP endpoint, as small as `t3_thread_launch` needs.""" + """T3 Code's MCP endpoint, as small as `delegate_task` and + `t3_thread_launch` need. `delegate_task` fails, as when the lead's + thread has no run on, while `server.delegating` is false.""" def log_message(self, *_): pass @@ -43,7 +45,12 @@ def do_POST(self): if 'id' not in message: return self.reply(202) self.server.calls.put(message['params']) + if message['params']['name'] == 'delegate_task' and not self.server.delegating: + refused = {'content': [{'type': 'text', 'text': 'parent_not_active'}], 'isError': True} + return self.reply(200, {'jsonrpc': '2.0', 'id': message['id'], 'result': refused}) result = {'threadId': 'thread-2', 'link': 't3://thread/thread-2', 'status': 'started'} + if message['params']['name'] == 'delegate_task': + result = {'taskId': 'node-1', 'childThreadId': 'thread-2', 'status': 'running'} self.reply(200, {'jsonrpc': '2.0', 'id': message['id'], 'result': {'content': [{'type': 'text', 'text': json.dumps(result)}], 'structuredContent': result}}) @@ -114,7 +121,7 @@ def setUp(self): self.daemon = SocketClient(self.binary, self.store, self.url, 'echo') self.addCleanup(self.daemon.close) self.t3 = http.server.ThreadingHTTPServer(('127.0.0.1', 0), T3Tools) - self.t3.calls, self.t3.headers = queue.Queue(), [] + self.t3.calls, self.t3.headers, self.t3.delegating = queue.Queue(), [], True threading.Thread(target=self.t3.serve_forever, daemon=True).start() self.addCleanup(self.t3.server_close) self.addCleanup(self.t3.shutdown) @@ -172,6 +179,8 @@ def test_a_thread_is_its_folders_lead_and_a_task_it_starts_opens_as_a_thread_of_ thread.send({'id': asked['id'], 'result': {'outcome': {'outcome': 'selected', 'optionId': 'allow'}}}) answered, updates = thread.answer_of(prompt) self.assertEqual(answered['result'], {'stopReason': 'end_turn'}) + # The task was T3 Code's child of this prompt before it was answered. + delegated = self.t3.calls.get_nowait() kinds = [u['sessionUpdate'] for u in updates] self.assertEqual(kinds[-1], 'state_update') self.assertEqual(updates[-1]['state'], 'idle') @@ -188,23 +197,18 @@ def test_a_thread_is_its_folders_lead_and_a_task_it_starts_opens_as_a_thread_of_ prompts = self.daemon.request('prompts', bot='calc.lead', turn=turn['turn'])['result']['prompts'] self.assertEqual(prompts[0]['text'], 'script') - # The task opens as a thread of its own, in its worktree, with its - # brief as the first message, through this thread's own tools. - launch = self.t3.calls.get(timeout=30) - self.assertEqual(launch['name'], 't3_thread_launch') - worktree = self.path / '.agent/worktrees/calc.fix' + # The task opens as the lead thread's child, with its brief as the + # first message, through this thread's own tools. + self.assertEqual(delegated['name'], 'delegate_task') turns = self.daemon.request('turns', bot='calc.fix')['result']['turns'] brief = self.daemon.request('prompts', bot='calc.fix', turn=turns[0]['turn'])['result']['prompts'][0]['text'] - self.assertEqual(launch['arguments']['title'], 'fix') - self.assertEqual(launch['arguments']['message'], brief) - self.assertEqual(launch['arguments']['workspaceStrategy'], - {'type': 'existing_worktree', 'worktreePath': str(worktree)}) + self.assertEqual(delegated['arguments'], {'task': brief, 'title': 'fix', 'clientRequestId': 'calc.fix'}) self.assertTrue(all(h.get('authorization') == 'Bearer thread-1' or h.get('Authorization') == 'Bearer thread-1' for h in self.t3.headers)) - # That thread is the task's, not a lead made for its folder: it - # answers with the task's work. - child, child_session = self.open(worktree) + # That thread, which T3 Code opens in the lead's folder, is the + # task's, not the folder's lead: it answers with the task's work. + child, child_session = self.open(repo) answered, updates = child.answer_of(child.ask('session/prompt', { 'sessionId': child_session, 'prompt': self.wrapped(brief)})) self.assertEqual(answered['result']['stopReason'], 'end_turn') @@ -213,6 +217,12 @@ def test_a_thread_is_its_folders_lead_and_a_task_it_starts_opens_as_a_thread_of_ self.assertEqual(kept.read_text(), 'calc.fix') self.assertEqual([b['name'] for b in self.daemon.request('bots')['result']['bots']], ['calc.fix', 'calc.lead']) + # T3 Code tells the lead's thread its child ended; the lead hears of + # its task from the lookout, so that message starts no turn. + answered, _ = thread.answer_of(thread.ask('session/prompt', {'sessionId': session, 'prompt': self.wrapped( + 'Delegated task node-1 reached a terminal state. Use task_status with taskId node-1 to read the result.')})) + self.assertEqual(answered['result'], {'stopReason': 'end_turn'}) + self.assertEqual(len(self.daemon.request('turns', bot='calc.lead')['result']['turns']), 1) def test_a_prompt_that_steers_is_answered_when_the_turn_it_joined_ends(self): repo = self.repo() @@ -220,6 +230,7 @@ def test_a_prompt_that_steers_is_answered_when_the_turn_it_joined_ends(self): instructions='Lead.', declare=[declared('start_task', inherit=False)], approve=['start_task'], approver='manual') thread, session = self.open(repo) + self.t3.delegating = False self.model.call_script = [('start_task', {'name': 'fix', 'brief': 'Fix calc.py.'})] first = thread.ask('session/prompt', {'sessionId': session, 'prompt': self.wrapped('script')}) [asked] = [m for m in thread.until(lambda m: m.get('method') == 'session/request_permission') @@ -235,7 +246,13 @@ def test_a_prompt_that_steers_is_answered_when_the_turn_it_joined_ends(self): self.assertEqual(updates[-1]['state'], 'idle') turns = self.daemon.request('turns', bot='calc.lead')['result']['turns'] self.assertEqual(sorted(t['status'] for t in turns), ['completed', 'steered']) - self.assertEqual(self.t3.calls.get(timeout=30)['name'], 't3_thread_launch') + # T3 Code would not take the task as a child: it opens as a thread of + # its own, in its worktree. + self.assertEqual(self.t3.calls.get(timeout=30)['name'], 'delegate_task') + launch = self.t3.calls.get(timeout=30) + self.assertEqual(launch['name'], 't3_thread_launch') + self.assertEqual(launch['arguments']['workspaceStrategy'], + {'type': 'existing_worktree', 'worktreePath': str(self.path / '.agent/worktrees/calc.fix')}) def test_a_turn_the_lead_takes_on_its_own_shows_in_the_thread(self): repo = self.repo() From b759dffcf3f7c73e39e42879be9b732642e2e325 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:01:30 +0000 Subject: [PATCH 15/17] Port #351's fix for main's build (trigger restamp) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFMvxuKK3qzEb4j48d --- sonny/src/trigger.rs | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/sonny/src/trigger.rs b/sonny/src/trigger.rs index 35ed6cacb..b6385e408 100644 --- a/sonny/src/trigger.rs +++ b/sonny/src/trigger.rs @@ -2374,15 +2374,25 @@ pub fn restamp(places: &Places, identity: &str, lineage: &str, launchd: Loader) return; }; // Read again under the lock: an `rm`, `add` or fire may have come first. - let path = places.plist(&trigger.name); + let path = places.job(&trigger.name); let Ok(text) = std::fs::read_to_string(&path) else { continue; }; - let stamped = text.replacen( - &format!("{}", escape(identity)), - &format!("{}", escape(lineage)), - 1, - ); + let stamped = match is_plist(&text) { + true => text.replacen( + &format!("{}", escape(identity)), + &format!("{}", escape(lineage)), + 1, + ), + false => { + let flag = quoted("--store-id", true); + text.replacen( + &format!("{flag} {}", quoted(identity, true)), + &format!("{flag} {}", quoted(lineage, true)), + 1, + ) + } + }; if stamped == text { continue; } @@ -2398,14 +2408,11 @@ pub fn restamp(places: &Places, identity: &str, lineage: &str, launchd: Loader) /// Once Sonny knows its daemon: [`restamp`] the triggers made on its store. pub fn restamp_at_start(identity: Option<&str>, lineage: Option<&str>) { - if !cfg!(target_os = "macos") { - return; - } let (Ok(places), Some(identity), Some(lineage)) = (Places::home(), identity, lineage) else { return; }; let (identity, lineage) = (identity.to_owned(), lineage.to_owned()); - std::thread::spawn(move || restamp(&places, &identity, &lineage, &launchctl)); + std::thread::spawn(move || restamp(&places, &identity, &lineage, &schedule)); } /// What `trigger add` was asked. @@ -4288,7 +4295,12 @@ mod tests { #[test] fn a_trigger_made_by_its_store_file_s_identity_names_its_lineage_instead() { - let w = World::new("restamp"); + for w in [World::new("restamp"), World::systemd("restamp-unit")] { + restamps(w); + } + } + + fn restamps(w: World) { let elsewhere = Trigger { name: "q.other".into(), store_id: "another-store".into(), From 8c51be3f2690d9018fd1746894eca3a5357f576f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:05:09 +0000 Subject: [PATCH 16/17] sonny acp: every held prompt is answered, and none waits past 30 s Answers held while a task's thread opens are kept in a list, so a second prompt whose turn ends meanwhile cannot drop the first. The hold ends after 30 s whatever the opening does, fallback launch included; a thread still opening opens on its own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFMvxuKK3qzEb4j48d --- sonny/src/acp.rs | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/sonny/src/acp.rs b/sonny/src/acp.rs index 62f5d5e7f..493de34dc 100644 --- a/sonny/src/acp.rs +++ b/sonny/src/acp.rs @@ -29,6 +29,9 @@ const VERSION: i64 = 2; /// The most of a call's output a thread is shown. const OUTPUT_BYTES: usize = 8 * 1024; +/// The longest the person's prompt waits for its tasks' threads to open. +const OPENING: std::time::Duration = std::time::Duration::from_secs(30); + /// JSON-RPC's codes for what Sonny refuses. const INVALID_PARAMS: i64 = -32602; const NOT_FOUND: i64 = -32601; @@ -407,9 +410,10 @@ struct State { /// idle; a new lead starts on it. picked: Option, /// Tasks whose threads are opening, as children of the person's prompt - /// while it runs, and that prompt's answer, held until they are open. + /// while it runs, and the answers of the prompts whose turns ended + /// meanwhile, held until they are open. opening: usize, - held: Option<(Value, Value)>, + held: Vec<(Value, Value)>, } struct Bound { @@ -1099,20 +1103,28 @@ impl Session { .and_then(|sessions| sessions.get(&self.id).cloned()); if let Some(session) = this { state.opening += 1; + let session_id = session.id.clone(); let agent = agent.clone(); tokio::spawn(async move { - let at = Where { - project: &project, - workspace: &workspace, - thread: &session.cwd, - }; - open_task(&agent, &session.id, &client, &at, &arguments, &call).await; + let (opener, thread) = (agent.clone(), session.cwd.clone()); + let opening = tokio::spawn(async move { + let at = Where { + project: &project, + workspace: &workspace, + thread: &thread, + }; + let session = &session_id; + open_task(&opener, session, &client, &at, &arguments, &call).await; + }); + // The person's prompt waits for its tasks' threads, + // not for ever: one still opening opens on its own. + let _ = tokio::time::timeout(OPENING, opening).await; let mut state = session.state.lock().await; state.opening -= 1; - if state.opening == 0 - && let Some((prompt, result)) = state.held.take() - { - agent.out.answer(&prompt, result); + if state.opening == 0 { + for (prompt, result) in std::mem::take(&mut state.held) { + agent.out.answer(&prompt, result); + } } }); } @@ -1168,7 +1180,7 @@ impl Session { // A task T3 Code opens as a child of this prompt opens // while its run is on. if state.opening > 0 { - state.held = Some((prompt, result)); + state.held.push((prompt, result)); } else { out.answer(&prompt, result); } @@ -1515,7 +1527,7 @@ async fn open_task( // may open yet, so its task still waits for it and no other thread // is opened for it. let delegating = t3.call("delegate_task", child); - match tokio::time::timeout(std::time::Duration::from_secs(30), delegating).await { + match tokio::time::timeout(OPENING, delegating).await { Ok(Ok(delegated)) => return Ok(delegated), Ok(Err(_)) => {} Err(_) => return Ok(Value::Null), From 73ac97d30caab4543de5ff7c942387334d764d6c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:17:29 +0000 Subject: [PATCH 17/17] test_acp: the steered prompt's idle comes before its held answer, not last The answer now waits for the task's thread, whose "Opened" update can come after the idle one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFMvxuKK3qzEb4j48d --- tests/test_acp.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_acp.py b/tests/test_acp.py index c03e37955..4d70c79ad 100644 --- a/tests/test_acp.py +++ b/tests/test_acp.py @@ -243,7 +243,8 @@ def test_a_prompt_that_steers_is_answered_when_the_turn_it_joined_ends(self): thread.send({'id': asked['id'], 'result': {'outcome': {'outcome': 'selected', 'optionId': 'allow'}}}) answered, updates = thread.answer_of(second) self.assertEqual(answered['result'], {'stopReason': 'end_turn'}) - self.assertEqual(updates[-1]['state'], 'idle') + # Idle before the answer, which waited for the task's thread. + self.assertIn('idle', [u.get('state') for u in updates if u['sessionUpdate'] == 'state_update']) turns = self.daemon.request('turns', bot='calc.lead')['result']['turns'] self.assertEqual(sorted(t['status'] for t in turns), ['completed', 'steered']) # T3 Code would not take the task as a child: it opens as a thread of