Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
dbd2c0d
feat(agent-host): run a saved agent without the desktop app
loganj Oct 16, 2026
85a4099
fix(agent-host): keep the feed listening, and stop what a process left
loganj Oct 16, 2026
16d6ecd
fix(agent-host): keep messages heard during a partial outage
loganj Oct 16, 2026
bcb7d6e
fix(agent-host): give a missed burst to the agent as it has room
loganj Oct 16, 2026
1d3047d
docs(agent-host): say why the host keeps catch-up progress in memory
loganj Oct 16, 2026
29d340c
fix(agent-host): answer a Solo chat at top level, from its label
loganj Oct 18, 2026
9af7dd3
fix(agent-host): keep listening when the relay refuses an unreadable …
loganj Oct 18, 2026
7bd33ee
test(agent-host): count a killed, unreaped process as gone
loganj Oct 18, 2026
cf12515
fix(agent-host): give a Solo chat's turns its owner's Canvas
loganj Oct 19, 2026
f9abc74
fix(agent-host): read a Solo chat's Canvas from the relay's writer
loganj Oct 20, 2026
12a80ab
fix(agent-host): hear a channel the agent joins or rejoins in any batch
loganj Oct 20, 2026
277cec5
fix(agent-host): sign shared notes, as the app now does
loganj Oct 20, 2026
6587124
fix(agent-host): match main's runner and custody contracts
loganj Oct 23, 2026
5f5a427
refactor(agent-host): hear the agent through the app's live client
loganj Oct 23, 2026
a833d61
refactor(agent-host): use the app's relay session as the agent
loganj Oct 23, 2026
0381772
refactor(agents): one queue for record changes, in the app and the host
loganj Oct 23, 2026
aee03fd
Run the standalone host's agent custody in the app's executable
loganj Oct 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ dist-buzz-mcp/
# its bundled resources.
src-tauri/resources/bin/*
!src-tauri/resources/bin/.gitkeep
dist-agent-host/
1 change: 1 addition & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"!!dist-author",
"!!dist-plugins",
"!!dist-buzz-mcp",
"!!dist-agent-host",
"!!.profiles",
"!!src-tauri/target",
"!!src-tauri/gen",
Expand Down
17 changes: 17 additions & 0 deletions crates/agent-controller/src/app_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,23 @@ impl AppAgent {
}
key.profile_auth(url, body)
}
/// Its NIP-42 login to its own community, with its owner attestation.
pub fn login(&self, key: &Secret, relay: &str, challenge: &str) -> Result<serde_json::Value> {
if relay != self.relay {
return Err("Agents sign in only to their community".into());
}
if challenge.is_empty() || challenge.len() > 256 {
return Err("Invalid login challenge".into());
}
let tags = self.attested(
key,
vec![
vec!["relay".into(), relay.into()],
vec!["challenge".into(), challenge.into()],
],
)?;
key.signed(22242, String::new(), tags)
}
/// NIP-98 authorization for Git over HTTP to repository `repo` of `owner`
/// in its community, and the URL it is bound to. Git reuses it for every
/// request of one clone, fetch or push; the community honors it for a minute.
Expand Down
28 changes: 28 additions & 0 deletions crates/agent-controller/src/app_agent/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,34 @@ fn signs_git_auth_for_one_repository_in_its_own_community() {
assert!(agent.git_auth(&other, &owner, "hive").is_err());
}

#[test]
fn signs_a_login_only_to_its_own_community() {
let dir = tempfile::tempdir().unwrap();
let credentials = Memory::default();
let (_, agent) = created(dir.path(), &credentials);
let key = agent.read_key(&credentials).unwrap();
let saved: Vec<String> = serde_json::from_str(&agent.auth).unwrap();
let login = agent.login(&key, &agent.relay, "challenge").unwrap();
assert_eq!(login["kind"], 22242);
assert_eq!(login["pubkey"], agent.pubkey);
let tags: Vec<Vec<String>> = serde_json::from_value(login["tags"].clone()).unwrap();
assert_eq!(
tags,
[
vec!["relay".to_owned(), agent.relay.clone()],
vec!["challenge".to_owned(), "challenge".to_owned()],
saved
]
);
assert!(agent
.login(&key, "wss://elsewhere.test", "challenge")
.is_err());
assert!(agent.login(&key, &agent.relay, "").is_err());
assert!(agent.login(&key, &agent.relay, &"c".repeat(257)).is_err());
let other = Secret::generate().unwrap();
assert!(agent.login(&other, &agent.relay, "challenge").is_err());
}

#[test]
fn signs_uploads_and_memory_for_its_own_community_and_owner() {
let dir = tempfile::tempdir().unwrap();
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"preview": "vite preview",
"author:build": "node scripts/build-author.mjs",
"buzz-mcp": "vite build --config vite.buzz-mcp.config.ts",
"agent-host": "vite build --config vite.agent-host.config.ts",
"typecheck": "tsc --noEmit",
"format": "biome format --write .",
"format:check": "biome format .",
Expand Down
192 changes: 192 additions & 0 deletions src-tauri/src/agent_host.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
//! The app's custody of one agent without its window, for `buzz-agent-host`,
//! which runs the agent while the app is closed. It answers the requests the
//! WebView sends for its agents (`app_agents.rs`, `agent_state.rs`) with the
//! same code: one JSON line per request on stdin, one per answer on stdout.
//! Like a dev build, it runs the agent only while the installed app does not.
//! It exits when stdin closes, and the OS then lets go of its hosting locks.
use crate::agent_state::{self, AgentState};
use crate::app_agents::{self, AppAgentHost};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_json::{json, Value};
use std::io::{BufRead, Write};
use std::sync::{Arc, Mutex, PoisonError};

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Request {
id: u64,
command: String,
#[serde(default)]
args: Value,
}

/// Serves custody of agent `pubkey` of the app `identifier` until stdin closes.
pub(crate) fn serve(identifier: &str, pubkey: &str) -> i32 {
let Some(data) = dirs::data_dir() else {
eprintln!("Could not resolve local agent storage");
return 1;
};
let agents = data.join(identifier).join(crate::sign_out::AGENTS);
let host = Arc::new(AppAgentHost::new(
Ok(agents.join("identities.json")),
Err("Not available without the app".into()),
false,
));
let state = Arc::new(AgentState::new(Ok(agents.join("state"))));
let out = Arc::new(Mutex::new(std::io::stdout()));
let pubkey: Arc<str> = pubkey.into();
for line in std::io::stdin().lock().lines() {
let Ok(line) = line else { break };
let (host, state, out, pubkey) = (host.clone(), state.clone(), out.clone(), pubkey.clone());
tauri::async_runtime::spawn(async move {
let (id, answer) = match serde_json::from_str::<Request>(&line) {
Ok(request) => (
json!(request.id),
answer(&host, &state, &pubkey, &request.command, &request.args).await,
),
Err(_) => (Value::Null, Err("Invalid request".into())),
};
let line = match answer {
Ok(value) => json!({ "id": id, "ok": value }),
Err(error) => json!({ "id": id, "error": error }),
};
let mut out = out.lock().unwrap_or_else(PoisonError::into_inner);
let _ = writeln!(out, "{line}");
let _ = out.flush();
});
}
0
}

fn arg<T: DeserializeOwned>(args: &Value, name: &str) -> Result<T, String> {
serde_json::from_value(args.get(name).cloned().unwrap_or(Value::Null))
.map_err(|_| format!("Invalid {name}"))
}

fn reply(value: impl Serialize) -> Result<Value, String> {
serde_json::to_value(value).map_err(|_| "Invalid answer".into())
}

async fn answer(
host: &AppAgentHost,
state: &AgentState,
agent: &str,
command: &str,
args: &Value,
) -> Result<Value, String> {
// Every agent's name, for team instructions; anything else only for its own.
if command != "app_agent_list"
&& !command.starts_with("app_agent_state_")
&& arg::<String>(args, "pubkey")? != agent
{
return Err("Not this host's agent".into());
}
let pubkey = agent.to_owned();
match command {
"app_agent_list" => reply(app_agents::list(host).await?),
"app_agent_claim" => reply(app_agents::claim(host, pubkey).await?),
"app_agent_release" => {
app_agents::release(host, &pubkey);
Ok(Value::Null)
}
"app_agent_publish" => app_agents::publish(host, pubkey, arg(args, "event")?).await,
"app_agent_query" => app_agents::query(host, pubkey, arg(args, "filters")?).await,
"app_agent_search_profiles" => reply(
app_agents::find_profiles(host, pubkey, arg(args, "query")?, arg(args, "limit")?)
.await?,
),
"app_agent_upload" => {
app_agents::upload_blob(host, pubkey, arg(args, "data")?, arg(args, "mime")?).await
}
"app_agent_media" => reply(app_agents::media(host, pubkey, arg(args, "url")?).await?),
"app_agent_git_auth" => {
app_agents::git_auth(host, pubkey, arg(args, "owner")?, arg(args, "repo")?).await
}
"app_agent_remember" => {
app_agents::remember(
host,
pubkey,
arg(args, "slug")?,
arg(args, "body")?,
arg(args, "after")?,
)
.await
}
"app_agent_state_load" => reply(state.load()?),
"app_agent_state_set" => reply(agent_state::save(
state,
arg(args, "key")?,
arg(args, "value")?,
arg(args, "absent")?,
)?),
// What the app does as the owner, this host does as the agent: it
// hears the agent's community and reads its memory with the agent's key.
"app_agent_login" => {
app_agents::login(host, pubkey, arg(args, "relay")?, arg(args, "challenge")?).await
}
"app_agent_read_auth" => {
app_agents::read_auth(host, pubkey, arg(args, "url")?, arg(args, "body")?).await
}
"app_agent_memories" => app_agents::memories(host, pubkey, arg(args, "slug")?).await,
_ => Err("Not available without the app".into()),
}
}

#[cfg(test)]
mod tests {
use super::*;

#[tokio::test]
async fn serves_only_its_agent_and_the_shared_state() {
let dir = tempfile::tempdir().unwrap();
let host = AppAgentHost::new(
Ok(dir.path().join("identities.json")),
Err("unused".into()),
false,
);
let state = AgentState::new(Ok(dir.path().join("state")));
let agent = "a".repeat(64);
let ask = |command: &'static str, args: Value| {
let (host, state, agent) = (&host, &state, agent.clone());
async move { answer(host, state, &agent, command, &args).await }
};
let other = json!({ "pubkey": "b".repeat(64) });
for command in ["app_agent_claim", "app_agent_publish", "app_agent_login"] {
assert_eq!(
ask(command, other.clone()).await,
Err("Not this host's agent".into())
);
}
assert_eq!(
ask("app_agent_create", json!({ "pubkey": agent })).await,
Err("Not available without the app".into())
);
assert_eq!(ask("app_agent_list", Value::Null).await, Ok(json!([])));
// A missing agent is refused by the app's own custody.
assert!(ask(
"app_agent_publish",
json!({ "pubkey": agent, "event": { "kind": 9, "content": "" } })
)
.await
.is_err());
assert_eq!(
ask("app_agent_claim", json!({ "pubkey": agent })).await,
Ok(json!(true))
);
ask("app_agent_state_set", json!({ "key": "k", "value": "v" }))
.await
.unwrap();
assert_eq!(
ask(
"app_agent_state_set",
json!({ "key": "k", "value": "w", "absent": true })
)
.await,
Ok(json!("v"))
);
assert_eq!(
ask("app_agent_state_load", Value::Null).await,
Ok(json!({ "k": "v" }))
);
}
}
13 changes: 13 additions & 0 deletions src-tauri/src/agent_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,14 @@ pub(crate) async fn app_agent_state_set(
key: String,
value: Option<String>,
absent: Option<bool>,
) -> Result<Option<String>, String> {
save(&state, key, value, absent)
}
pub(crate) fn save(
state: &AgentState,
key: String,
value: Option<String>,
absent: Option<bool>,
) -> Result<Option<String>, String> {
match (value, absent.unwrap_or(false)) {
(Some(value), true) => state.add(&key, &value),
Expand Down Expand Up @@ -305,6 +313,11 @@ mod tests {
);
assert_eq!(key.len(), 251);
assert!(encode(&key).len() > NAME_LIMIT);
// The name the agent host's `fileName` gives it (storage.test.ts).
assert_eq!(
file_name(&key),
"~2418a80781b72ec15bca6c4423fa41f0937d29e13b19fe1247bf0daaa571773f"
);
for key in [key.as_str(), &":".repeat(86), &"k".repeat(NAME_LIMIT + 1)] {
state.set(key, Some("{\"a\":1}")).unwrap();
let name = file_name(key);
Expand Down
Loading
Loading