1use async_trait::async_trait;
17use serde_json::{json, Value};
18
19use crate::error::{Error, Result};
20use crate::mail_route::{process_ancestry, resolve_caller, LiveSessions};
21use crate::mail_send::SendOptions;
22use crate::mailbox::{mail_root, Mailbox};
23use crate::tools::{Tool, ToolContext, ToolRegistry};
24use crate::HarnessHomes;
25
26static STARTED_AS: std::sync::OnceLock<Option<(std::path::PathBuf, (u64, u64))>> =
30 std::sync::OnceLock::new();
31
32#[cfg(unix)]
33fn file_identity(path: &std::path::Path) -> Option<(u64, u64)> {
34 use std::os::unix::fs::MetadataExt;
35 std::fs::metadata(path)
36 .ok()
37 .map(|metadata| (metadata.dev(), metadata.ino()))
38}
39
40#[cfg(not(unix))]
41fn file_identity(_path: &std::path::Path) -> Option<(u64, u64)> {
42 None
43}
44
45fn started_as() -> &'static Option<(std::path::PathBuf, (u64, u64))> {
46 STARTED_AS.get_or_init(|| {
47 let path = std::env::current_exe().ok()?;
48 let identity = file_identity(&path)?;
49 Some((path, identity))
50 })
51}
52
53fn replaced_by() -> Option<std::path::PathBuf> {
55 let (path, identity) = started_as().as_ref()?;
56 let now = file_identity(path)?;
57 (now != *identity).then(|| path.clone())
58}
59
60pub fn registry() -> ToolRegistry {
62 started_as();
63 let mut registry = ToolRegistry::new();
64 registry.register(ListAgents);
65 registry.register(SendMessage);
66 registry.register(ReadMessages);
67 registry
68}
69
70fn caller(tool: &str, homes: &HarnessHomes) -> Result<crate::mail_route::Caller> {
71 resolve_caller(homes, &process_ancestry()).map_err(|message| Error::tool(tool, message))
72}
73
74struct ListAgents;
75
76#[async_trait]
77impl Tool for ListAgents {
78 fn name(&self) -> &str {
79 "list_agents"
80 }
81
82 fn description(&self) -> &str {
83 "List the coding-agent sessions you can message, of every harness (Claude Code, Codex, \
84 sessions supercode hosts): each one's name, harness, status (busy, idle, hosted), how it \
85 is reached, and address. `query` keeps the ones whose name or address contains it."
86 }
87
88 fn parameters(&self) -> Value {
89 json!({
90 "type": "object",
91 "properties": {
92 "query": {"type": "string", "description": "Keep sessions whose name or address contains this."}
93 },
94 "additionalProperties": false
95 })
96 }
97
98 async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<String> {
99 let query = args
100 .get("query")
101 .and_then(Value::as_str)
102 .map(str::to_lowercase);
103 let sessions = LiveSessions::read(&HarnessHomes::default());
104 let rows: Vec<String> = sessions
105 .all()
106 .iter()
107 .filter(|session| {
108 query.as_deref().is_none_or(|query| {
109 session.name.to_lowercase().contains(query)
110 || session.address.to_string().to_lowercase().contains(query)
111 })
112 })
113 .map(|session| {
114 format!(
115 "{} ({}, {}, {}) {}",
116 session.name,
117 session.address.harness,
118 session.status,
119 session.door.name(),
120 session.address
121 )
122 })
123 .collect();
124 Ok(if rows.is_empty() {
125 "No session to message matches.".into()
126 } else {
127 rows.join("\n")
128 })
129 }
130}
131
132struct SendMessage;
133
134#[async_trait]
135impl Tool for SendMessage {
136 fn name(&self) -> &str {
137 "send_message"
138 }
139
140 fn description(&self) -> &str {
141 "Send a message to another coding-agent session, of any harness, on this machine or \
142 another of your team's (`name@machine`). `to` is a name from list_agents, `name@machine`, \
143 or an address. To answer a message, send to its `from` with `reply_to` set to its id. \
144 The result says whether it was delivered, queued, held for approval, refused or stored."
145 }
146
147 fn parameters(&self) -> Value {
148 json!({
149 "type": "object",
150 "properties": {
151 "to": {"type": "string", "description": "A session name, name@machine, or address."},
152 "message": {"type": "string", "description": "The message."},
153 "subject": {"type": "string", "description": "A short subject shown on the unopened envelope."},
154 "reply_to": {"type": "string", "description": "Id of the message this answers."},
155 "notify_when_idle": {"type": "boolean", "description": "Also get one notice when the receiver's next turn ends."}
156 },
157 "required": ["to", "message"],
158 "additionalProperties": false
159 })
160 }
161
162 async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<String> {
163 let text = |key: &str| args.get(key).and_then(Value::as_str).map(str::to_string);
164 let (Some(to), Some(message)) = (text("to"), text("message")) else {
165 return Err(Error::tool(self.name(), "`to` and `message` are required"));
166 };
167 if message.trim().is_empty() {
168 return Err(Error::tool(
169 self.name(),
170 "Nothing was sent: the message is empty.",
171 ));
172 }
173 if let Some(installed) = replaced_by() {
174 return send_through(&installed, self.name(), &to, &message, &args).await;
175 }
176 let homes = HarnessHomes::default();
177 let caller = caller(self.name(), &homes)?;
178 let options = SendOptions {
179 subject: text("subject"),
180 in_reply_to: text("reply_to"),
181 notify_when_idle: args
182 .get("notify_when_idle")
183 .and_then(Value::as_bool)
184 .unwrap_or(false),
185 ..Default::default()
186 };
187 let outcome = crate::mail_send::send(&homes, &caller, &to, &message, options)
188 .await
189 .map_err(|error| Error::tool(self.name(), error.to_string()))?;
190 if outcome.code == 0 {
191 Ok(outcome.text)
192 } else {
193 Err(Error::tool(self.name(), outcome.text))
194 }
195 }
196}
197
198async fn send_through(
201 installed: &std::path::Path,
202 tool: &str,
203 to: &str,
204 message: &str,
205 args: &Value,
206) -> Result<String> {
207 use tokio::io::AsyncWriteExt;
208 let mut command = tokio::process::Command::new(installed);
209 command.args(["message", "send", to]);
210 if let Some(subject) = args.get("subject").and_then(Value::as_str) {
211 command.args(["--subject", subject]);
212 }
213 if let Some(answered) = args.get("reply_to").and_then(Value::as_str) {
214 command.args(["--re", answered]);
215 }
216 if args.get("notify_when_idle").and_then(Value::as_bool) == Some(true) {
217 command.arg("--notify-when-idle");
218 }
219 command
220 .stdin(std::process::Stdio::piped())
221 .stdout(std::process::Stdio::piped())
222 .stderr(std::process::Stdio::piped());
223 let mut child = command.spawn().map_err(|error| {
224 Error::tool(
225 tool,
226 format!("could not start {}: {error}", installed.display()),
227 )
228 })?;
229 if let Some(mut stdin) = child.stdin.take() {
230 stdin
231 .write_all(message.as_bytes())
232 .await
233 .map_err(|error| Error::tool(tool, error.to_string()))?;
234 }
235 let output = child
236 .wait_with_output()
237 .await
238 .map_err(|error| Error::tool(tool, error.to_string()))?;
239 let said = |bytes: &[u8]| String::from_utf8_lossy(bytes).trim().to_string();
240 if output.status.success() {
241 Ok(said(&output.stdout))
242 } else {
243 let stderr = said(&output.stderr);
244 Err(Error::tool(
245 tool,
246 if stderr.is_empty() {
247 said(&output.stdout)
248 } else {
249 stderr
250 },
251 ))
252 }
253}
254
255struct ReadMessages;
256
257#[async_trait]
258impl Tool for ReadMessages {
259 fn name(&self) -> &str {
260 "read_messages"
261 }
262
263 fn description(&self) -> &str {
264 "Read your unread messages from other sessions, each with how to answer it. Messages \
265 usually arrive in your conversation by themselves; this reads any that are waiting."
266 }
267
268 fn parameters(&self) -> Value {
269 json!({"type": "object", "properties": {}, "additionalProperties": false})
270 }
271
272 async fn execute(&self, _args: Value, _ctx: &ToolContext) -> Result<String> {
273 let homes = HarnessHomes::default();
274 let caller = caller(self.name(), &homes)?;
275 let mailbox = Mailbox::open(&mail_root(), &caller.address)
276 .map_err(|error| Error::tool(self.name(), error.to_string()))?;
277 let claimed = mailbox
278 .claim_unread()
279 .map_err(|error| Error::tool(self.name(), error.to_string()))?;
280 let text = if claimed.is_empty() {
281 "No unread messages.".to_string()
282 } else {
283 claimed
284 .iter()
285 .map(|stored| stored.envelope.render())
286 .collect::<Vec<_>>()
287 .join("\n\n")
288 };
289 for stored in &claimed {
290 mailbox
291 .acknowledge(stored)
292 .map_err(|error| Error::tool(self.name(), error.to_string()))?;
293 }
294 Ok(text)
295 }
296}