feat(ai): /grant ai grants drive to every AI agent at once
CI / rust client (hh) (macos-latest) (push) Has been cancelled
CI / rust client (hh) (ubuntu-latest) (push) Has been cancelled
CI / rust coverage (push) Has been cancelled
CI / python server (3.10) (push) Has been cancelled
CI / python server (3.11) (push) Has been cancelled
CI / python server (3.12) (push) Has been cancelled
CI / headless e2e smoke (push) Has been cancelled
CI / dependency audit (push) Has been cancelled
CI / secret scanning (push) Has been cancelled
CI / rust client (hh) (macos-latest) (push) Has been cancelled
CI / rust client (hh) (ubuntu-latest) (push) Has been cancelled
CI / rust coverage (push) Has been cancelled
CI / python server (3.10) (push) Has been cancelled
CI / python server (3.11) (push) Has been cancelled
CI / python server (3.12) (push) Has been cancelled
CI / headless e2e smoke (push) Has been cancelled
CI / dependency audit (push) Has been cancelled
CI / secret scanning (push) Has been cancelled
So the owner never has to name each model. Tracks an ai_agents set (populated from `_ai` typing/stream frames and the "(ai) online" announce, pruned on leave); `/grant ai` intersects it with the live roster and grants all in one ACL broadcast. Help text gains a /grant ai row. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+36
-2
@@ -255,6 +255,10 @@ pub struct App {
|
|||||||
pub password: String,
|
pub password: String,
|
||||||
/// AI agents currently generating a reply — drives the "thinking" spinner.
|
/// AI agents currently generating a reply — drives the "thinking" spinner.
|
||||||
pub ai_typing: std::collections::HashSet<String>,
|
pub ai_typing: std::collections::HashSet<String>,
|
||||||
|
/// Every room member we've identified as an AI agent (via its `_ai` frames
|
||||||
|
/// or its `(ai) online` announce). Lets `/grant ai` grant drive to all of
|
||||||
|
/// them at once; pruned when a member leaves.
|
||||||
|
pub ai_agents: std::collections::HashSet<String>,
|
||||||
/// Live, in-progress reply text per streaming agent, shown as a transient
|
/// Live, in-progress reply text per streaming agent, shown as a transient
|
||||||
/// preview bubble until the final message lands. Keyed by agent name.
|
/// preview bubble until the final message lands. Keyed by agent name.
|
||||||
pub ai_stream: std::collections::HashMap<String, String>,
|
pub ai_stream: std::collections::HashMap<String, String>,
|
||||||
@@ -309,6 +313,7 @@ impl App {
|
|||||||
error: None,
|
error: None,
|
||||||
password: String::new(),
|
password: String::new(),
|
||||||
ai_typing: std::collections::HashSet::new(),
|
ai_typing: std::collections::HashSet::new(),
|
||||||
|
ai_agents: std::collections::HashSet::new(),
|
||||||
ai_stream: std::collections::HashMap::new(),
|
ai_stream: std::collections::HashMap::new(),
|
||||||
spin: 0,
|
spin: 0,
|
||||||
agent_sbx_allow: false,
|
agent_sbx_allow: false,
|
||||||
@@ -433,7 +438,14 @@ impl App {
|
|||||||
self.sys(format!("joined as {} †", self.me));
|
self.sys(format!("joined as {} †", self.me));
|
||||||
self.sys("/sbx <docker|podman|multipass|vbox|local> · /drive (F2 releases) · /ai start · /ai <question> · /send <user> <file> · /sendroom <file> · /pw show password · /help full command list · PgUp/PgDn scroll chat · ctrl-q quit");
|
self.sys("/sbx <docker|podman|multipass|vbox|local> · /drive (F2 releases) · /ai start · /ai <question> · /send <user> <file> · /sendroom <file> · /pw show password · /help full command list · PgUp/PgDn scroll chat · ctrl-q quit");
|
||||||
}
|
}
|
||||||
Net::Message(l) => self.push_line(l),
|
Net::Message(l) => {
|
||||||
|
// An agent announces itself with "<name> (ai) online …" — record
|
||||||
|
// it as an AI member so `/grant ai` can reach it before it acts.
|
||||||
|
if !l.system && l.text.starts_with(&format!("{} (ai) ", l.username)) {
|
||||||
|
self.ai_agents.insert(l.username.clone());
|
||||||
|
}
|
||||||
|
self.push_line(l);
|
||||||
|
}
|
||||||
Net::Roster { users, capacity } => {
|
Net::Roster { users, capacity } => {
|
||||||
self.users = users;
|
self.users = users;
|
||||||
self.capacity = capacity;
|
self.capacity = capacity;
|
||||||
@@ -443,11 +455,13 @@ impl App {
|
|||||||
if let Some(p) = self.users.iter().position(|u| u.user_id == uid) {
|
if let Some(p) = self.users.iter().position(|u| u.user_id == uid) {
|
||||||
let name = self.users.remove(p).username;
|
let name = self.users.remove(p).username;
|
||||||
self.ai_typing.remove(&name); // a departed agent isn't thinking
|
self.ai_typing.remove(&name); // a departed agent isn't thinking
|
||||||
|
self.ai_agents.remove(&name); // …nor an AI member any more
|
||||||
self.ai_stream.remove(&name); // …nor streaming a reply
|
self.ai_stream.remove(&name); // …nor streaming a reply
|
||||||
self.sys(format!("{name} left"));
|
self.sys(format!("{name} left"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Net::AiTyping { name, on } => {
|
Net::AiTyping { name, on } => {
|
||||||
|
self.ai_agents.insert(name.clone()); // an `_ai` frame ⇒ AI member
|
||||||
if on {
|
if on {
|
||||||
self.ai_typing.insert(name);
|
self.ai_typing.insert(name);
|
||||||
} else {
|
} else {
|
||||||
@@ -455,6 +469,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Net::AiStream { name, text, done } => {
|
Net::AiStream { name, text, done } => {
|
||||||
|
self.ai_agents.insert(name.clone()); // an `_ai` frame ⇒ AI member
|
||||||
if done {
|
if done {
|
||||||
self.ai_stream.remove(&name);
|
self.ai_stream.remove(&name);
|
||||||
} else {
|
} else {
|
||||||
@@ -2566,7 +2581,26 @@ fn handle_command(
|
|||||||
if !app.is_owner() {
|
if !app.is_owner() {
|
||||||
app.sys("only the sandbox owner can /grant");
|
app.sys("only the sandbox owner can /grant");
|
||||||
} else if target.is_empty() {
|
} else if target.is_empty() {
|
||||||
app.sys("usage: /grant <user>");
|
app.sys("usage: /grant <user> (or /grant ai for every AI agent)");
|
||||||
|
} else if target == "ai" {
|
||||||
|
// Grant drive to every AI agent in the room in one shot, so the owner
|
||||||
|
// never has to name each model. Intersect the known-AI set with the
|
||||||
|
// live roster so departed agents are skipped.
|
||||||
|
let agents: Vec<String> = app
|
||||||
|
.users
|
||||||
|
.iter()
|
||||||
|
.map(|u| u.username.clone())
|
||||||
|
.filter(|n| app.ai_agents.contains(n))
|
||||||
|
.collect();
|
||||||
|
if agents.is_empty() {
|
||||||
|
app.sys("no AI agents in the room to grant — /ai start one first");
|
||||||
|
} else {
|
||||||
|
for n in &agents {
|
||||||
|
app.drivers.insert(n.clone());
|
||||||
|
}
|
||||||
|
broadcast_acl(out_tx, room, app);
|
||||||
|
app.sys(format!("granted drive to all AI agents: {}", agents.join(", ")));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
app.drivers.insert(target.to_string());
|
app.drivers.insert(target.to_string());
|
||||||
broadcast_acl(out_tx, room, app);
|
broadcast_acl(out_tx, room, app);
|
||||||
|
|||||||
@@ -364,6 +364,7 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
|
|||||||
"/grant <user|agent>",
|
"/grant <user|agent>",
|
||||||
"let a member OR an AI agent drive the shell",
|
"let a member OR an AI agent drive the shell",
|
||||||
),
|
),
|
||||||
|
kv("/grant ai", "grant drive to every AI agent at once"),
|
||||||
kv("/revoke <user|agent>", "take back sandbox drive permission"),
|
kv("/revoke <user|agent>", "take back sandbox drive permission"),
|
||||||
kv("/sudo <user>", "delegate VM superuser (real sudo)"),
|
kv("/sudo <user>", "delegate VM superuser (real sudo)"),
|
||||||
kv("/unsudo <user>", "revoke VM superuser"),
|
kv("/unsudo <user>", "revoke VM superuser"),
|
||||||
|
|||||||
Reference in New Issue
Block a user