1use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, BTreeSet};
9use std::path::{Path, PathBuf};
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::time::{SystemTime, UNIX_EPOCH};
12use thiserror::Error;
13
14use crate::core::{
15 db::{Database, DbError},
16 types::{BootstrapEvidenceArgs, Drawer, SourceType},
17 utils::{build_bootstrap_evidence_drawer_id, current_timestamp},
18};
19
20use super::inbox::{
21 InboxMessage, MAX_MESSAGE_SIZE, MAX_PENDING_MESSAGES, MAX_TOTAL_INBOX_BYTES,
22 encode_project_identity, project_identity,
23};
24use super::peek::{format_rfc3339, parse_rfc3339};
25
26const MAX_EVENT_MESSAGE_PREVIEW_CHARS: usize = 200;
27const PRESENCE_STALE_AFTER_SECONDS: i64 = 10 * 60;
28
29static BUS_EVENT_COUNTER: AtomicU64 = AtomicU64::new(0);
30
31#[derive(Debug, Clone, Copy)]
32struct MessageMetadata<'a> {
33 thread_id: Option<&'a str>,
34 channel: Option<&'a str>,
35}
36
37#[derive(Debug, Error)]
38pub enum BusError {
39 #[error("invalid agent id `{0}`: expected 1-64 ASCII letters, digits, `_`, `-`, or `.`")]
40 InvalidAgentId(String),
41 #[error("invalid tool name `{0}`: expected 1-64 ASCII letters, digits, `_`, `-`, or `.`")]
42 InvalidTool(String),
43 #[error("unsupported transport `{0}`: expected inbox|tmux")]
44 UnsupportedTransport(String),
45 #[error("invalid channel `{0}`: expected 1-64 ASCII letters, digits, `_`, `-`, or `.`")]
46 InvalidChannel(String),
47 #[error("invalid thread id `{0}`: expected 1-64 ASCII letters, digits, `_`, `-`, or `.`")]
48 InvalidThreadId(String),
49 #[error("unknown channel `{0}`")]
50 UnknownChannel(String),
51 #[error("channel `{0}` must contain at least one agent")]
52 EmptyChannel(String),
53 #[error("tmux_target is required when transport=tmux")]
54 TmuxTargetRequired,
55 #[error("tmux delivery failed with status {0}")]
56 TmuxFailed(String),
57 #[error("tmux capture failed with status {0}")]
58 TmuxCaptureFailed(String),
59 #[error("tmux probe failed with status {0}")]
60 TmuxProbeFailed(String),
61 #[error("agent `{0}` is not registered with transport=tmux")]
62 NotTmuxAgent(String),
63 #[error("invalid tmux capture line count `{0}`: expected 1..=500")]
64 InvalidLineCount(usize),
65 #[error("invalid session id `{0}`: expected 1-64 ASCII letters, digits, `_`, `-`, or `.`")]
66 InvalidSessionId(String),
67 #[error("session `{0}` must contain at least one agent")]
68 EmptySession(String),
69 #[error("unknown session `{0}`")]
70 UnknownSession(String),
71 #[error("unsupported session status `{0}`: expected active|paused|closed")]
72 InvalidSessionStatus(String),
73 #[error("unsupported cowork capture summary source `{0}`: expected handoff")]
74 UnsupportedCaptureSource(String),
75 #[error("capture execute requires an open database")]
76 MissingCaptureDatabase,
77 #[error("invalid timestamp `{0}`: expected RFC3339")]
78 InvalidTimestamp(String),
79 #[error("unknown delivery message id `{0}`")]
80 UnknownDelivery(String),
81 #[error("delivery `{message_id}` is addressed to `{target_agent_id}`, not `{agent_id}`")]
82 DeliveryTargetMismatch {
83 message_id: String,
84 target_agent_id: String,
85 agent_id: String,
86 },
87 #[error("cannot ack failed delivery `{0}`")]
88 CannotAckFailed(String),
89 #[error("unknown source agent `{0}`")]
90 UnknownSource(String),
91 #[error("unknown target agent `{0}`")]
92 UnknownTarget(String),
93 #[error("unknown agent `{0}`")]
94 UnknownAgent(String),
95 #[error("cannot send to self `{0}`")]
96 SelfSend(String),
97 #[error("message content exceeds {MAX_MESSAGE_SIZE} bytes: got {0} bytes")]
98 MessageTooLarge(usize),
99 #[error(
100 "agent inbox full: {current_count} messages / {current_bytes} bytes pending \
101 (limits: {MAX_PENDING_MESSAGES} messages, {MAX_TOTAL_INBOX_BYTES} bytes)"
102 )]
103 InboxFull {
104 current_count: usize,
105 current_bytes: u64,
106 },
107 #[error("io error: {0}")]
108 Io(#[from] std::io::Error),
109 #[error("json error: {0}")]
110 Json(#[from] serde_json::Error),
111 #[error("db error: {0}")]
112 Db(#[from] DbError),
113 #[error("legacy inbox error: {0}")]
114 LegacyInbox(#[from] super::inbox::InboxError),
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct AgentRecord {
119 pub agent_id: String,
120 pub tool: String,
121 pub transport: String,
122 pub tmux_target: Option<String>,
123 pub registered_at: String,
124 pub updated_at: String,
125 #[serde(default)]
126 pub last_seen_at: Option<String>,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, Default)]
130pub struct AgentRegistry {
131 pub agents: BTreeMap<String, AgentRecord>,
132 #[serde(default)]
133 pub channels: BTreeMap<String, ChannelRecord>,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct ChannelRecord {
138 pub channel: String,
139 pub agents: Vec<String>,
140 pub updated_at: String,
141}
142
143#[derive(Debug, Clone)]
144pub struct RegisterAgentRequest {
145 pub agent_id: String,
146 pub tool: String,
147 pub transport: String,
148 pub tmux_target: Option<String>,
149}
150
151#[derive(Debug, Clone)]
152pub struct SendRequest {
153 pub from: String,
154 pub targets: Vec<String>,
155 pub message: String,
156 pub operation: SendOperation,
157 pub thread_id: Option<String>,
158 pub channel: Option<String>,
159}
160
161#[derive(Debug, Clone, Copy)]
162pub enum SendOperation {
163 Send,
164 Broadcast,
165}
166
167impl SendOperation {
168 fn event_type(self) -> &'static str {
169 match self {
170 Self::Send => "send",
171 Self::Broadcast => "broadcast",
172 }
173 }
174}
175
176#[derive(Debug, Clone)]
177pub struct SendReport {
178 pub delivered: Vec<DeliveryReport>,
179}
180
181#[derive(Debug, Clone)]
182pub struct DeliveryReport {
183 pub message_id: String,
184 pub target_agent_id: String,
185 pub transport: String,
186 pub inbox_path: Option<PathBuf>,
187 pub inbox_size_after: Option<u64>,
188 pub tmux_target: Option<String>,
189 pub thread_id: Option<String>,
190 pub channel: Option<String>,
191}
192
193#[derive(Debug, Clone)]
194pub struct AgentStatus {
195 pub record: AgentRecord,
196 pub presence: String,
197 pub pending_count: usize,
198 pub pending_bytes: u64,
199 pub preview: Vec<InboxMessage>,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct BusEvent {
204 pub event_id: String,
205 pub occurred_at: String,
206 pub event_type: String,
207 pub status: String,
208 pub actor_agent_id: Option<String>,
209 pub target_agent_ids: Vec<String>,
210 pub transport: Option<String>,
211 pub message_preview: Option<String>,
212 pub details: BTreeMap<String, String>,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct DeliveryStatus {
217 pub message_id: String,
218 pub event_type: String,
219 pub status: String,
220 pub from: String,
221 pub target_agent_id: String,
222 pub transport: String,
223 pub message_preview: Option<String>,
224 pub thread_id: Option<String>,
225 pub channel: Option<String>,
226 pub delivered_at: String,
227 pub updated_at: String,
228 pub acked_by: Option<String>,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct TmuxPeek {
233 pub agent_id: String,
234 pub tmux_target: String,
235 pub lines: usize,
236 pub content: String,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct DoctorReport {
241 pub status: String,
242 pub agent_count: usize,
243 pub channel_count: usize,
244 pub session_count: usize,
245 pub stale_agents: usize,
246 pub never_seen_agents: usize,
247 pub pending_deliveries: usize,
248 pub warnings: Vec<String>,
249 pub tmux: Vec<TmuxProbeReport>,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct TmuxProbeReport {
254 pub agent_id: String,
255 pub tmux_target: String,
256 pub status: String,
257 pub detail: Option<String>,
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize, Default)]
261pub struct SessionRegistry {
262 pub sessions: BTreeMap<String, TeamSession>,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct TeamSession {
267 pub session_id: String,
268 pub title: String,
269 pub goal: Option<String>,
270 pub agents: Vec<String>,
271 pub channels: Vec<String>,
272 pub thread_id: Option<String>,
273 pub status: String,
274 pub created_at: String,
275 pub updated_at: String,
276}
277
278#[derive(Debug, Clone)]
279pub struct CreateSessionRequest {
280 pub session_id: String,
281 pub title: String,
282 pub goal: Option<String>,
283 pub agents: Vec<String>,
284 pub channels: Vec<String>,
285 pub thread_id: Option<String>,
286}
287
288#[derive(Debug, Clone, Default)]
289pub struct HandoffFilters {
290 pub thread_id: Option<String>,
291 pub channel: Option<String>,
292 pub session_id: Option<String>,
293 pub limit: Option<usize>,
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct HandoffSummary {
298 pub filters: HandoffSummaryFilters,
299 pub sessions: Vec<TeamSession>,
300 pub agents: Vec<AgentStatusSummary>,
301 pub pending_deliveries: Vec<DeliveryStatus>,
302 pub recent_events: Vec<BusEvent>,
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct HandoffSummaryFilters {
307 pub thread_id: Option<String>,
308 pub channel: Option<String>,
309 pub session_id: Option<String>,
310 pub limit: usize,
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct AgentStatusSummary {
315 pub agent_id: String,
316 pub tool: String,
317 pub presence: String,
318 pub pending_count: usize,
319}
320
321#[derive(Debug, Clone)]
322pub struct CoworkCaptureRequest {
323 pub summary_source: String,
324 pub wing: String,
325 pub room: Option<String>,
326 pub thread_id: Option<String>,
327 pub channel: Option<String>,
328 pub session_id: Option<String>,
329 pub note: Option<String>,
330 pub execute: bool,
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct CoworkCaptureReport {
335 pub writes: bool,
336 pub drawer_id: Option<String>,
337 pub wing: String,
338 pub room: Option<String>,
339 pub source: String,
340 pub content: String,
341}
342
343pub fn validate_agent_id(agent_id: &str) -> Result<(), BusError> {
344 if is_safe_bus_token(agent_id) {
345 Ok(())
346 } else {
347 Err(BusError::InvalidAgentId(agent_id.to_string()))
348 }
349}
350
351fn validate_tool(tool: &str) -> Result<(), BusError> {
352 if is_safe_bus_token(tool) {
353 Ok(())
354 } else {
355 Err(BusError::InvalidTool(tool.to_string()))
356 }
357}
358
359fn validate_channel(channel: &str) -> Result<(), BusError> {
360 if is_safe_bus_token(channel) {
361 Ok(())
362 } else {
363 Err(BusError::InvalidChannel(channel.to_string()))
364 }
365}
366
367fn validate_thread_id(thread_id: &str) -> Result<(), BusError> {
368 if is_safe_bus_token(thread_id) {
369 Ok(())
370 } else {
371 Err(BusError::InvalidThreadId(thread_id.to_string()))
372 }
373}
374
375fn validate_session_id(session_id: &str) -> Result<(), BusError> {
376 if is_safe_bus_token(session_id) {
377 Ok(())
378 } else {
379 Err(BusError::InvalidSessionId(session_id.to_string()))
380 }
381}
382
383fn validate_session_status(status: &str) -> Result<(), BusError> {
384 match status {
385 "active" | "paused" | "closed" => Ok(()),
386 other => Err(BusError::InvalidSessionStatus(other.to_string())),
387 }
388}
389
390fn is_safe_bus_token(value: &str) -> bool {
391 !value.is_empty()
392 && value.len() <= 64
393 && value
394 .bytes()
395 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
396}
397
398pub fn project_bus_dir(mempal_home: &Path, cwd: &Path) -> Result<PathBuf, BusError> {
399 let identity = project_identity(cwd);
400 let encoded = encode_project_identity(&identity)?;
401 Ok(mempal_home.join("cowork-bus").join(encoded))
402}
403
404pub fn registry_path(mempal_home: &Path, cwd: &Path) -> Result<PathBuf, BusError> {
405 Ok(project_bus_dir(mempal_home, cwd)?.join("agents.json"))
406}
407
408pub fn agent_inbox_path(
409 mempal_home: &Path,
410 cwd: &Path,
411 agent_id: &str,
412) -> Result<PathBuf, BusError> {
413 validate_agent_id(agent_id)?;
414 Ok(project_bus_dir(mempal_home, cwd)?
415 .join("inbox")
416 .join(format!("{agent_id}.jsonl")))
417}
418
419pub fn events_path(mempal_home: &Path, cwd: &Path) -> Result<PathBuf, BusError> {
420 Ok(project_bus_dir(mempal_home, cwd)?.join("events.jsonl"))
421}
422
423pub fn sessions_path(mempal_home: &Path, cwd: &Path) -> Result<PathBuf, BusError> {
424 Ok(project_bus_dir(mempal_home, cwd)?.join("sessions.json"))
425}
426
427pub fn load_registry(mempal_home: &Path, cwd: &Path) -> Result<AgentRegistry, BusError> {
428 let path = registry_path(mempal_home, cwd)?;
429 if !path.exists() {
430 return Ok(AgentRegistry::default());
431 }
432 let raw = std::fs::read_to_string(path)?;
433 if raw.trim().is_empty() {
434 return Ok(AgentRegistry::default());
435 }
436 Ok(serde_json::from_str(&raw)?)
437}
438
439fn save_registry(mempal_home: &Path, cwd: &Path, registry: &AgentRegistry) -> Result<(), BusError> {
440 let path = registry_path(mempal_home, cwd)?;
441 if let Some(parent) = path.parent() {
442 std::fs::create_dir_all(parent)?;
443 }
444 let raw = serde_json::to_string_pretty(registry)?;
445 std::fs::write(path, raw)?;
446 Ok(())
447}
448
449pub fn load_sessions(mempal_home: &Path, cwd: &Path) -> Result<SessionRegistry, BusError> {
450 let path = sessions_path(mempal_home, cwd)?;
451 if !path.exists() {
452 return Ok(SessionRegistry::default());
453 }
454 let raw = std::fs::read_to_string(path)?;
455 if raw.trim().is_empty() {
456 return Ok(SessionRegistry::default());
457 }
458 Ok(serde_json::from_str(&raw)?)
459}
460
461fn save_sessions(
462 mempal_home: &Path,
463 cwd: &Path,
464 sessions: &SessionRegistry,
465) -> Result<(), BusError> {
466 let path = sessions_path(mempal_home, cwd)?;
467 if let Some(parent) = path.parent() {
468 std::fs::create_dir_all(parent)?;
469 }
470 let raw = serde_json::to_string_pretty(sessions)?;
471 std::fs::write(path, raw)?;
472 Ok(())
473}
474
475pub fn register_agent(
476 mempal_home: &Path,
477 cwd: &Path,
478 request: RegisterAgentRequest,
479) -> Result<AgentRecord, BusError> {
480 validate_agent_id(&request.agent_id)?;
481 validate_tool(&request.tool)?;
482 if request.transport != "inbox" && request.transport != "tmux" {
483 return Err(BusError::UnsupportedTransport(request.transport));
484 }
485 if request.transport == "tmux"
486 && request
487 .tmux_target
488 .as_deref()
489 .map(str::trim)
490 .unwrap_or_default()
491 .is_empty()
492 {
493 return Err(BusError::TmuxTargetRequired);
494 }
495
496 let now = format_rfc3339(SystemTime::now());
497 let mut registry = load_registry(mempal_home, cwd)?;
498 let registered_at = registry
499 .agents
500 .get(&request.agent_id)
501 .map(|existing| existing.registered_at.clone())
502 .unwrap_or_else(|| now.clone());
503 let record = AgentRecord {
504 agent_id: request.agent_id.clone(),
505 tool: request.tool,
506 transport: request.transport,
507 tmux_target: request.tmux_target,
508 registered_at,
509 updated_at: now.clone(),
510 last_seen_at: Some(now),
511 };
512 registry
513 .agents
514 .insert(request.agent_id.clone(), record.clone());
515 save_registry(mempal_home, cwd, ®istry)?;
516 let mut details = BTreeMap::new();
517 details.insert("tool".to_string(), record.tool.clone());
518 if let Some(tmux_target) = &record.tmux_target {
519 details.insert("tmux_target".to_string(), tmux_target.clone());
520 }
521 append_bus_event(
522 mempal_home,
523 cwd,
524 "register",
525 "registered",
526 Some(record.agent_id.clone()),
527 vec![record.agent_id.clone()],
528 Some(record.transport.clone()),
529 None,
530 details,
531 )?;
532 Ok(record)
533}
534
535pub fn list_agent_status(mempal_home: &Path, cwd: &Path) -> Result<Vec<AgentStatus>, BusError> {
536 list_agent_status_at(mempal_home, cwd, None)
537}
538
539pub fn list_agent_status_at(
540 mempal_home: &Path,
541 cwd: &Path,
542 now: Option<&str>,
543) -> Result<Vec<AgentStatus>, BusError> {
544 let now_seconds = match now {
545 Some(now) => {
546 parse_rfc3339(now).ok_or_else(|| BusError::InvalidTimestamp(now.to_string()))?
547 }
548 None => current_unix_seconds(),
549 };
550 let registry = load_registry(mempal_home, cwd)?;
551 let mut statuses = Vec::new();
552 for record in registry.agents.values() {
553 let path = agent_inbox_path(mempal_home, cwd, &record.agent_id)?;
554 let (pending_count, pending_bytes, preview) = read_inbox_stats(&path)?;
555 let presence = presence_for(record.last_seen_at.as_deref(), now_seconds);
556 statuses.push(AgentStatus {
557 record: record.clone(),
558 presence,
559 pending_count,
560 pending_bytes,
561 preview,
562 });
563 }
564 Ok(statuses)
565}
566
567pub fn heartbeat_agent(
568 mempal_home: &Path,
569 cwd: &Path,
570 agent_id: &str,
571 seen_at: Option<&str>,
572) -> Result<AgentRecord, BusError> {
573 validate_agent_id(agent_id)?;
574 let seen_at = match seen_at {
575 Some(seen_at) => {
576 parse_rfc3339(seen_at)
577 .ok_or_else(|| BusError::InvalidTimestamp(seen_at.to_string()))?;
578 seen_at.to_string()
579 }
580 None => format_rfc3339(SystemTime::now()),
581 };
582 let mut registry = load_registry(mempal_home, cwd)?;
583 let record = registry
584 .agents
585 .get_mut(agent_id)
586 .ok_or_else(|| BusError::UnknownAgent(agent_id.to_string()))?;
587 record.last_seen_at = Some(seen_at.clone());
588 record.updated_at = seen_at.clone();
589 let record = record.clone();
590 save_registry(mempal_home, cwd, ®istry)?;
591
592 let mut details = BTreeMap::new();
593 details.insert("last_seen_at".to_string(), seen_at);
594 append_bus_event(
595 mempal_home,
596 cwd,
597 "heartbeat",
598 "seen",
599 Some(agent_id.to_string()),
600 vec![agent_id.to_string()],
601 Some(record.transport.clone()),
602 None,
603 details,
604 )?;
605 Ok(record)
606}
607
608pub fn set_channel(
609 mempal_home: &Path,
610 cwd: &Path,
611 channel: &str,
612 agents: Vec<String>,
613) -> Result<ChannelRecord, BusError> {
614 validate_channel(channel)?;
615 let agents = dedup_targets(agents)?;
616 if agents.is_empty() {
617 return Err(BusError::EmptyChannel(channel.to_string()));
618 }
619 let mut registry = load_registry(mempal_home, cwd)?;
620 for agent_id in &agents {
621 if !registry.agents.contains_key(agent_id) {
622 return Err(BusError::UnknownAgent(agent_id.clone()));
623 }
624 }
625 let record = ChannelRecord {
626 channel: channel.to_string(),
627 agents,
628 updated_at: format_rfc3339(SystemTime::now()),
629 };
630 registry
631 .channels
632 .insert(channel.to_string(), record.clone());
633 save_registry(mempal_home, cwd, ®istry)?;
634 let mut details = BTreeMap::new();
635 details.insert("channel".to_string(), channel.to_string());
636 details.insert("agents".to_string(), record.agents.join(","));
637 append_bus_event(
638 mempal_home,
639 cwd,
640 "channel_set",
641 "updated",
642 None,
643 record.agents.clone(),
644 None,
645 None,
646 details,
647 )?;
648 Ok(record)
649}
650
651pub fn list_channels(mempal_home: &Path, cwd: &Path) -> Result<Vec<ChannelRecord>, BusError> {
652 let registry = load_registry(mempal_home, cwd)?;
653 Ok(registry.channels.values().cloned().collect())
654}
655
656pub fn create_session(
657 mempal_home: &Path,
658 cwd: &Path,
659 request: CreateSessionRequest,
660) -> Result<TeamSession, BusError> {
661 validate_session_id(&request.session_id)?;
662 if let Some(thread_id) = &request.thread_id {
663 validate_thread_id(thread_id)?;
664 }
665 let agents = dedup_targets(request.agents)?;
666 if agents.is_empty() {
667 return Err(BusError::EmptySession(request.session_id));
668 }
669
670 let registry = load_registry(mempal_home, cwd)?;
671 for agent_id in &agents {
672 if !registry.agents.contains_key(agent_id) {
673 return Err(BusError::UnknownAgent(agent_id.clone()));
674 }
675 }
676 let mut channels = Vec::new();
677 let mut seen_channels = BTreeSet::new();
678 for channel in request.channels {
679 validate_channel(&channel)?;
680 if !registry.channels.contains_key(&channel) {
681 return Err(BusError::UnknownChannel(channel));
682 }
683 if seen_channels.insert(channel.clone()) {
684 channels.push(channel);
685 }
686 }
687
688 let now = format_rfc3339(SystemTime::now());
689 let mut sessions = load_sessions(mempal_home, cwd)?;
690 let created_at = sessions
691 .sessions
692 .get(&request.session_id)
693 .map(|existing| existing.created_at.clone())
694 .unwrap_or_else(|| now.clone());
695 let session = TeamSession {
696 session_id: request.session_id.clone(),
697 title: request.title,
698 goal: request.goal,
699 agents,
700 channels,
701 thread_id: request.thread_id,
702 status: "active".to_string(),
703 created_at,
704 updated_at: now,
705 };
706 sessions
707 .sessions
708 .insert(request.session_id.clone(), session.clone());
709 save_sessions(mempal_home, cwd, &sessions)?;
710
711 let mut details = BTreeMap::new();
712 details.insert("session_id".to_string(), session.session_id.clone());
713 details.insert("title".to_string(), session.title.clone());
714 details.insert("status".to_string(), session.status.clone());
715 if let Some(thread_id) = &session.thread_id {
716 details.insert("thread_id".to_string(), thread_id.clone());
717 }
718 append_bus_event(
719 mempal_home,
720 cwd,
721 "session_create",
722 "created",
723 None,
724 session.agents.clone(),
725 None,
726 None,
727 details,
728 )?;
729 Ok(session)
730}
731
732pub fn list_sessions(mempal_home: &Path, cwd: &Path) -> Result<Vec<TeamSession>, BusError> {
733 let sessions = load_sessions(mempal_home, cwd)?;
734 Ok(sessions.sessions.values().cloned().collect())
735}
736
737pub fn update_session_status(
738 mempal_home: &Path,
739 cwd: &Path,
740 session_id: &str,
741 status: &str,
742) -> Result<TeamSession, BusError> {
743 validate_session_id(session_id)?;
744 validate_session_status(status)?;
745 let mut sessions = load_sessions(mempal_home, cwd)?;
746 let session = sessions
747 .sessions
748 .get_mut(session_id)
749 .ok_or_else(|| BusError::UnknownSession(session_id.to_string()))?;
750 session.status = status.to_string();
751 session.updated_at = format_rfc3339(SystemTime::now());
752 let session = session.clone();
753 save_sessions(mempal_home, cwd, &sessions)?;
754
755 let mut details = BTreeMap::new();
756 details.insert("session_id".to_string(), session.session_id.clone());
757 details.insert("status".to_string(), session.status.clone());
758 append_bus_event(
759 mempal_home,
760 cwd,
761 "session_status",
762 status,
763 None,
764 session.agents.clone(),
765 None,
766 None,
767 details,
768 )?;
769 Ok(session)
770}
771
772pub fn send_channel(
773 mempal_home: &Path,
774 cwd: &Path,
775 from: String,
776 channel: String,
777 message: String,
778 thread_id: Option<String>,
779) -> Result<SendReport, BusError> {
780 validate_channel(&channel)?;
781 let registry = load_registry(mempal_home, cwd)?;
782 let record = registry
783 .channels
784 .get(&channel)
785 .ok_or_else(|| BusError::UnknownChannel(channel.clone()))?;
786 send(
787 mempal_home,
788 cwd,
789 SendRequest {
790 from,
791 targets: record.agents.clone(),
792 message,
793 operation: SendOperation::Broadcast,
794 thread_id,
795 channel: Some(channel),
796 },
797 )
798}
799
800pub fn tmux_peek_agent(
801 mempal_home: &Path,
802 cwd: &Path,
803 agent_id: &str,
804 lines: usize,
805) -> Result<TmuxPeek, BusError> {
806 validate_agent_id(agent_id)?;
807 if !(1..=500).contains(&lines) {
808 return Err(BusError::InvalidLineCount(lines));
809 }
810 let registry = load_registry(mempal_home, cwd)?;
811 let record = registry
812 .agents
813 .get(agent_id)
814 .ok_or_else(|| BusError::UnknownAgent(agent_id.to_string()))?;
815 if record.transport != "tmux" {
816 return Err(BusError::NotTmuxAgent(agent_id.to_string()));
817 }
818 let tmux_target = record
819 .tmux_target
820 .as_deref()
821 .ok_or(BusError::TmuxTargetRequired)?;
822 let content = capture_tmux(tmux_target, lines)?;
823 Ok(TmuxPeek {
824 agent_id: agent_id.to_string(),
825 tmux_target: tmux_target.to_string(),
826 lines,
827 content,
828 })
829}
830
831pub fn doctor(
832 mempal_home: &Path,
833 cwd: &Path,
834 now: Option<&str>,
835 probe_tmux: bool,
836) -> Result<DoctorReport, BusError> {
837 let statuses = list_agent_status_at(mempal_home, cwd, now)?;
838 let channels = list_channels(mempal_home, cwd)?;
839 let sessions = list_sessions(mempal_home, cwd)?;
840 let deliveries = list_delivery_statuses(mempal_home, cwd, None)?;
841 let pending_deliveries = deliveries
842 .iter()
843 .filter(|delivery| delivery.status == "pending")
844 .count();
845 let stale_agents = statuses
846 .iter()
847 .filter(|status| status.presence == "stale")
848 .count();
849 let never_seen_agents = statuses
850 .iter()
851 .filter(|status| status.presence == "never_seen")
852 .count();
853
854 let mut warnings = Vec::new();
855 if statuses.is_empty() {
856 warnings.push("no registered agents".to_string());
857 }
858 if stale_agents > 0 {
859 warnings.push(format!("stale agents: {stale_agents}"));
860 }
861 if pending_deliveries > 0 {
862 warnings.push(format!("pending deliveries: {pending_deliveries}"));
863 }
864 if never_seen_agents > 0 {
865 warnings.push(format!("never seen agents: {never_seen_agents}"));
866 }
867
868 let mut tmux = Vec::new();
869 for status in &statuses {
870 if status.record.transport != "tmux" {
871 continue;
872 }
873 let Some(tmux_target) = status.record.tmux_target.clone() else {
874 warnings.push(format!(
875 "tmux agent {} has no target",
876 status.record.agent_id
877 ));
878 tmux.push(TmuxProbeReport {
879 agent_id: status.record.agent_id.clone(),
880 tmux_target: String::new(),
881 status: "missing_target".to_string(),
882 detail: None,
883 });
884 continue;
885 };
886 let probe = if probe_tmux {
887 probe_tmux_target(&tmux_target)
888 } else {
889 TmuxProbeReport {
890 agent_id: status.record.agent_id.clone(),
891 tmux_target: tmux_target.clone(),
892 status: "not_probed".to_string(),
893 detail: None,
894 }
895 };
896 if probe.status == "failed" {
897 warnings.push(format!(
898 "tmux target failed: {} {}",
899 status.record.agent_id, tmux_target
900 ));
901 }
902 tmux.push(TmuxProbeReport {
903 agent_id: status.record.agent_id.clone(),
904 tmux_target,
905 ..probe
906 });
907 }
908
909 let status = if warnings.is_empty() {
910 "ok".to_string()
911 } else {
912 "warning".to_string()
913 };
914 Ok(DoctorReport {
915 status,
916 agent_count: statuses.len(),
917 channel_count: channels.len(),
918 session_count: sessions.len(),
919 stale_agents,
920 never_seen_agents,
921 pending_deliveries,
922 warnings,
923 tmux,
924 })
925}
926
927pub fn build_handoff_summary(
928 mempal_home: &Path,
929 cwd: &Path,
930 filters: HandoffFilters,
931) -> Result<HandoffSummary, BusError> {
932 if let Some(thread_id) = &filters.thread_id {
933 validate_thread_id(thread_id)?;
934 }
935 if let Some(channel) = &filters.channel {
936 validate_channel(channel)?;
937 }
938 if let Some(session_id) = &filters.session_id {
939 validate_session_id(session_id)?;
940 }
941 let limit = filters.limit.unwrap_or(20);
942 let sessions = list_sessions(mempal_home, cwd)?
943 .into_iter()
944 .filter(|session| {
945 filters
946 .session_id
947 .as_ref()
948 .is_none_or(|session_id| &session.session_id == session_id)
949 })
950 .filter(|session| {
951 filters
952 .thread_id
953 .as_ref()
954 .is_none_or(|thread_id| session.thread_id.as_ref() == Some(thread_id))
955 })
956 .filter(|session| {
957 filters
958 .channel
959 .as_ref()
960 .is_none_or(|channel| session.channels.iter().any(|item| item == channel))
961 })
962 .collect::<Vec<_>>();
963 let agents = list_agent_status(mempal_home, cwd)?
964 .into_iter()
965 .map(|status| AgentStatusSummary {
966 agent_id: status.record.agent_id,
967 tool: status.record.tool,
968 presence: status.presence,
969 pending_count: status.pending_count,
970 })
971 .collect::<Vec<_>>();
972 let pending_deliveries = list_delivery_statuses(mempal_home, cwd, None)?
973 .into_iter()
974 .filter(|delivery| delivery.status == "pending")
975 .filter(|delivery| {
976 filters
977 .thread_id
978 .as_ref()
979 .is_none_or(|thread_id| delivery.thread_id.as_ref() == Some(thread_id))
980 })
981 .filter(|delivery| {
982 filters
983 .channel
984 .as_ref()
985 .is_none_or(|channel| delivery.channel.as_ref() == Some(channel))
986 })
987 .collect::<Vec<_>>();
988 let mut recent_events = list_events(mempal_home, cwd, Some(limit))?
989 .into_iter()
990 .filter(|event| {
991 filters.thread_id.as_ref().is_none_or(|thread_id| {
992 event.details.get("thread_id") == Some(thread_id)
993 || event.details.get("session_id").is_some_and(|session_id| {
994 sessions.iter().any(|session| {
995 &session.session_id == session_id
996 && session.thread_id.as_ref() == Some(thread_id)
997 })
998 })
999 })
1000 })
1001 .filter(|event| {
1002 filters
1003 .channel
1004 .as_ref()
1005 .is_none_or(|channel| event.details.get("channel") == Some(channel))
1006 })
1007 .filter(|event| {
1008 filters
1009 .session_id
1010 .as_ref()
1011 .is_none_or(|session_id| event.details.get("session_id") == Some(session_id))
1012 })
1013 .collect::<Vec<_>>();
1014 if recent_events.len() > limit {
1015 recent_events.drain(0..recent_events.len() - limit);
1016 }
1017
1018 Ok(HandoffSummary {
1019 filters: HandoffSummaryFilters {
1020 thread_id: filters.thread_id,
1021 channel: filters.channel,
1022 session_id: filters.session_id,
1023 limit,
1024 },
1025 sessions,
1026 agents,
1027 pending_deliveries,
1028 recent_events,
1029 })
1030}
1031
1032pub fn capture_handoff_to_memory(
1033 db: Option<&Database>,
1034 mempal_home: &Path,
1035 cwd: &Path,
1036 request: CoworkCaptureRequest,
1037) -> Result<CoworkCaptureReport, BusError> {
1038 if request.summary_source != "handoff" {
1039 return Err(BusError::UnsupportedCaptureSource(request.summary_source));
1040 }
1041 let summary = build_handoff_summary(
1042 mempal_home,
1043 cwd,
1044 HandoffFilters {
1045 thread_id: request.thread_id,
1046 channel: request.channel,
1047 session_id: request.session_id,
1048 limit: Some(50),
1049 },
1050 )?;
1051 let capture_id = capture_id();
1052 let mut content = String::new();
1053 content.push_str("# Cowork Handoff Capture\n\n");
1054 content.push_str(&format!("capture_id: {capture_id}\n"));
1055 content.push_str(&format!("project: {}\n", cwd.display()));
1056 content.push_str("summary_source: handoff\n\n");
1057 if let Some(note) = request.note.as_deref() {
1058 content.push_str("## Note\n\n");
1059 content.push_str(note);
1060 content.push_str("\n\n");
1061 }
1062 content.push_str("## Handoff Summary\n\n");
1063 content.push_str(&format_handoff_plain(&summary));
1064
1065 let source_type = SourceType::Manual;
1066 let source_file = format!("cowork-capture://{capture_id}");
1067 let drawer_id = build_bootstrap_evidence_drawer_id(
1068 &request.wing,
1069 request.room.as_deref(),
1070 &content,
1071 &source_type,
1072 Some(source_file.as_str()),
1073 );
1074 if request.execute {
1075 let db = db.ok_or(BusError::MissingCaptureDatabase)?;
1076 let drawer = Drawer::new_bootstrap_evidence(BootstrapEvidenceArgs {
1077 id: drawer_id.clone(),
1078 content: content.clone(),
1079 wing: request.wing.clone(),
1080 room: request.room.clone(),
1081 source_file: Some(source_file),
1082 source_type,
1083 added_at: current_timestamp(),
1084 chunk_index: Some(0),
1085 importance: 3,
1086 });
1087 db.insert_drawer(&drawer)?;
1088 }
1089
1090 Ok(CoworkCaptureReport {
1091 writes: request.execute,
1092 drawer_id: Some(drawer_id),
1093 wing: request.wing,
1094 room: request.room,
1095 source: "handoff".to_string(),
1096 content,
1097 })
1098}
1099
1100pub fn send(mempal_home: &Path, cwd: &Path, request: SendRequest) -> Result<SendReport, BusError> {
1101 validate_agent_id(&request.from)?;
1102 if let Some(thread_id) = &request.thread_id {
1103 validate_thread_id(thread_id)?;
1104 }
1105 if let Some(channel) = &request.channel {
1106 validate_channel(channel)?;
1107 }
1108 if request.message.len() > MAX_MESSAGE_SIZE {
1109 return Err(BusError::MessageTooLarge(request.message.len()));
1110 }
1111
1112 let targets = dedup_targets(request.targets)?;
1113 if targets.iter().any(|target| target == &request.from) {
1114 return Err(BusError::SelfSend(request.from));
1115 }
1116
1117 let registry = load_registry(mempal_home, cwd)?;
1118 let source = registry
1119 .agents
1120 .get(&request.from)
1121 .ok_or_else(|| BusError::UnknownSource(request.from.clone()))?;
1122 let mut target_records = Vec::new();
1123 for target in &targets {
1124 validate_agent_id(target)?;
1125 let record = registry
1126 .agents
1127 .get(target)
1128 .ok_or_else(|| BusError::UnknownTarget(target.clone()))?;
1129 target_records.push(record.clone());
1130 }
1131
1132 let pushed_at = format_rfc3339(SystemTime::now());
1133 let event_type = request.operation.event_type();
1134 let message_preview = Some(message_preview(&request.message));
1135 let mut delivered = Vec::new();
1136 for target in target_records {
1137 match target.transport.as_str() {
1138 "inbox" => {
1139 let (inbox_path, inbox_size_after) = append_to_agent_inbox(
1140 mempal_home,
1141 cwd,
1142 &source.agent_id,
1143 &target.agent_id,
1144 &request.message,
1145 &pushed_at,
1146 MessageMetadata {
1147 thread_id: request.thread_id.as_deref(),
1148 channel: request.channel.as_deref(),
1149 },
1150 )?;
1151 let mut details = BTreeMap::new();
1152 details.insert(
1153 "inbox_path".to_string(),
1154 inbox_path.to_string_lossy().to_string(),
1155 );
1156 details.insert("inbox_size_after".to_string(), inbox_size_after.to_string());
1157 add_optional_detail(&mut details, "thread_id", request.thread_id.as_deref());
1158 add_optional_detail(&mut details, "channel", request.channel.as_deref());
1159 let event = append_bus_event(
1160 mempal_home,
1161 cwd,
1162 event_type,
1163 "delivered",
1164 Some(source.agent_id.clone()),
1165 vec![target.agent_id.clone()],
1166 Some("inbox".to_string()),
1167 message_preview.clone(),
1168 details,
1169 )?;
1170 delivered.push(DeliveryReport {
1171 message_id: event.event_id,
1172 target_agent_id: target.agent_id,
1173 transport: "inbox".to_string(),
1174 inbox_path: Some(inbox_path),
1175 inbox_size_after: Some(inbox_size_after),
1176 tmux_target: None,
1177 thread_id: request.thread_id.clone(),
1178 channel: request.channel.clone(),
1179 });
1180 }
1181 "tmux" => {
1182 let tmux_target = target
1183 .tmux_target
1184 .as_deref()
1185 .ok_or(BusError::TmuxTargetRequired)?;
1186 if let Err(err) = send_tmux(
1187 &source.agent_id,
1188 &target.agent_id,
1189 tmux_target,
1190 &request.message,
1191 ) {
1192 let mut details = BTreeMap::new();
1193 details.insert("tmux_target".to_string(), tmux_target.to_string());
1194 details.insert("error".to_string(), err.to_string());
1195 add_optional_detail(&mut details, "thread_id", request.thread_id.as_deref());
1196 add_optional_detail(&mut details, "channel", request.channel.as_deref());
1197 let _ = append_bus_event(
1198 mempal_home,
1199 cwd,
1200 event_type,
1201 "failed",
1202 Some(source.agent_id.clone()),
1203 vec![target.agent_id.clone()],
1204 Some("tmux".to_string()),
1205 message_preview.clone(),
1206 details,
1207 );
1208 return Err(err);
1209 }
1210 let mut details = BTreeMap::new();
1211 details.insert("tmux_target".to_string(), tmux_target.to_string());
1212 add_optional_detail(&mut details, "thread_id", request.thread_id.as_deref());
1213 add_optional_detail(&mut details, "channel", request.channel.as_deref());
1214 let event = append_bus_event(
1215 mempal_home,
1216 cwd,
1217 event_type,
1218 "delivered",
1219 Some(source.agent_id.clone()),
1220 vec![target.agent_id.clone()],
1221 Some("tmux".to_string()),
1222 message_preview.clone(),
1223 details,
1224 )?;
1225 delivered.push(DeliveryReport {
1226 message_id: event.event_id,
1227 target_agent_id: target.agent_id,
1228 transport: "tmux".to_string(),
1229 inbox_path: None,
1230 inbox_size_after: None,
1231 tmux_target: Some(tmux_target.to_string()),
1232 thread_id: request.thread_id.clone(),
1233 channel: request.channel.clone(),
1234 });
1235 }
1236 other => return Err(BusError::UnsupportedTransport(other.to_string())),
1237 }
1238 }
1239 Ok(SendReport { delivered })
1240}
1241
1242fn dedup_targets(targets: Vec<String>) -> Result<Vec<String>, BusError> {
1243 let mut seen = BTreeSet::new();
1244 let mut deduped = Vec::new();
1245 for target in targets {
1246 validate_agent_id(&target)?;
1247 if seen.insert(target.clone()) {
1248 deduped.push(target);
1249 }
1250 }
1251 Ok(deduped)
1252}
1253
1254fn append_to_agent_inbox(
1255 mempal_home: &Path,
1256 cwd: &Path,
1257 from: &str,
1258 target: &str,
1259 content: &str,
1260 pushed_at: &str,
1261 metadata: MessageMetadata<'_>,
1262) -> Result<(PathBuf, u64), BusError> {
1263 use std::io::Write;
1264
1265 let path = agent_inbox_path(mempal_home, cwd, target)?;
1266 if let Some(parent) = path.parent() {
1267 std::fs::create_dir_all(parent)?;
1268 }
1269
1270 let (existing_count, existing_bytes, _) = read_inbox_stats(&path)?;
1271 let msg = InboxMessage {
1272 pushed_at: pushed_at.to_string(),
1273 from: from.to_string(),
1274 content: content.to_string(),
1275 thread_id: metadata.thread_id.map(str::to_string),
1276 channel: metadata.channel.map(str::to_string),
1277 message_id: None,
1278 };
1279 let line = serde_json::to_string(&msg)?;
1280 let prospective_count = existing_count + 1;
1281 let prospective_bytes = existing_bytes.saturating_add(line.len() as u64 + 1);
1282 if prospective_count > MAX_PENDING_MESSAGES || prospective_bytes > MAX_TOTAL_INBOX_BYTES {
1283 return Err(BusError::InboxFull {
1284 current_count: existing_count,
1285 current_bytes: existing_bytes,
1286 });
1287 }
1288
1289 let mut file = std::fs::OpenOptions::new()
1290 .create(true)
1291 .append(true)
1292 .open(&path)?;
1293 writeln!(file, "{line}")?;
1294 file.flush()?;
1295 let size = std::fs::metadata(&path)?.len();
1296 Ok((path, size))
1297}
1298
1299fn send_tmux(from: &str, target: &str, tmux_target: &str, content: &str) -> Result<(), BusError> {
1300 let envelope = format!("[mempal bus from {from} to {target}] {content}");
1301 let status = std::process::Command::new("tmux")
1302 .args(["send-keys", "-t", tmux_target, "--", &envelope, "Enter"])
1303 .status()?;
1304 if status.success() {
1305 Ok(())
1306 } else {
1307 Err(BusError::TmuxFailed(
1308 status
1309 .code()
1310 .map(|code| code.to_string())
1311 .unwrap_or_else(|| "terminated by signal".to_string()),
1312 ))
1313 }
1314}
1315
1316fn capture_tmux(tmux_target: &str, lines: usize) -> Result<String, BusError> {
1317 let start = format!("-{lines}");
1318 let output = std::process::Command::new("tmux")
1319 .args(["capture-pane", "-t", tmux_target, "-p", "-S", &start])
1320 .output()?;
1321 if output.status.success() {
1322 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1323 } else {
1324 Err(BusError::TmuxCaptureFailed(
1325 output
1326 .status
1327 .code()
1328 .map(|code| code.to_string())
1329 .unwrap_or_else(|| "terminated by signal".to_string()),
1330 ))
1331 }
1332}
1333
1334fn probe_tmux_target(tmux_target: &str) -> TmuxProbeReport {
1335 match std::process::Command::new("tmux")
1336 .args(["has-session", "-t", tmux_target])
1337 .status()
1338 {
1339 Ok(status) if status.success() => TmuxProbeReport {
1340 agent_id: String::new(),
1341 tmux_target: tmux_target.to_string(),
1342 status: "ok".to_string(),
1343 detail: None,
1344 },
1345 Ok(status) => TmuxProbeReport {
1346 agent_id: String::new(),
1347 tmux_target: tmux_target.to_string(),
1348 status: "failed".to_string(),
1349 detail: Some(
1350 status
1351 .code()
1352 .map(|code| code.to_string())
1353 .unwrap_or_else(|| "terminated by signal".to_string()),
1354 ),
1355 },
1356 Err(error) => TmuxProbeReport {
1357 agent_id: String::new(),
1358 tmux_target: tmux_target.to_string(),
1359 status: "failed".to_string(),
1360 detail: Some(error.to_string()),
1361 },
1362 }
1363}
1364
1365fn capture_id() -> String {
1366 let nanos = SystemTime::now()
1367 .duration_since(UNIX_EPOCH)
1368 .map(|duration| duration.as_nanos())
1369 .unwrap_or_default();
1370 let seq = BUS_EVENT_COUNTER.fetch_add(1, Ordering::Relaxed);
1371 format!("cowork-capture-{nanos}-{seq}")
1372}
1373
1374pub fn drain_agent(
1375 mempal_home: &Path,
1376 cwd: &Path,
1377 agent_id: &str,
1378) -> Result<Vec<InboxMessage>, BusError> {
1379 validate_agent_id(agent_id)?;
1380 let registry = load_registry(mempal_home, cwd)?;
1381 if !registry.agents.contains_key(agent_id) {
1382 return Err(BusError::UnknownAgent(agent_id.to_string()));
1383 }
1384
1385 let path = agent_inbox_path(mempal_home, cwd, agent_id)?;
1386 let draining = path.with_extension("draining");
1387 match std::fs::rename(&path, &draining) {
1388 Ok(_) => {}
1389 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1390 append_drain_event(mempal_home, cwd, agent_id, 0, 0)?;
1391 return Ok(Vec::new());
1392 }
1393 Err(e) => return Err(e.into()),
1394 }
1395
1396 let raw = std::fs::read_to_string(&draining)?;
1397 let mut messages = Vec::new();
1398 for line in raw.lines() {
1399 let trimmed = line.trim();
1400 if trimmed.is_empty() {
1401 continue;
1402 }
1403 if let Ok(msg) = serde_json::from_str::<InboxMessage>(trimmed) {
1404 messages.push(msg);
1405 }
1406 }
1407 let _ = std::fs::remove_file(&draining);
1408 append_drain_event(mempal_home, cwd, agent_id, messages.len(), raw.len() as u64)?;
1409 Ok(messages)
1410}
1411
1412pub fn list_events(
1413 mempal_home: &Path,
1414 cwd: &Path,
1415 limit: Option<usize>,
1416) -> Result<Vec<BusEvent>, BusError> {
1417 let path = events_path(mempal_home, cwd)?;
1418 if !path.exists() {
1419 return Ok(Vec::new());
1420 }
1421
1422 let raw = std::fs::read_to_string(path)?;
1423 let mut events = Vec::new();
1424 for line in raw.lines() {
1425 let trimmed = line.trim();
1426 if trimmed.is_empty() {
1427 continue;
1428 }
1429 events.push(serde_json::from_str::<BusEvent>(trimmed)?);
1430 }
1431
1432 if let Some(limit) = limit
1433 && events.len() > limit
1434 {
1435 events.drain(0..events.len() - limit);
1436 }
1437 Ok(events)
1438}
1439
1440pub fn list_delivery_statuses(
1441 mempal_home: &Path,
1442 cwd: &Path,
1443 agent_id: Option<&str>,
1444) -> Result<Vec<DeliveryStatus>, BusError> {
1445 if let Some(agent_id) = agent_id {
1446 validate_agent_id(agent_id)?;
1447 }
1448 let events = list_events(mempal_home, cwd, None)?;
1449 let mut statuses = Vec::<DeliveryStatus>::new();
1450 let mut index_by_message_id = BTreeMap::<String, usize>::new();
1451
1452 for event in events {
1453 match event.event_type.as_str() {
1454 "send" | "broadcast" if event.status == "delivered" || event.status == "failed" => {
1455 let from = event.actor_agent_id.clone().unwrap_or_default();
1456 let transport = event.transport.clone().unwrap_or_default();
1457 for target in &event.target_agent_ids {
1458 let status = if event.status == "failed" {
1459 "failed"
1460 } else {
1461 "pending"
1462 };
1463 let delivery = DeliveryStatus {
1464 message_id: event.event_id.clone(),
1465 event_type: event.event_type.clone(),
1466 status: status.to_string(),
1467 from: from.clone(),
1468 target_agent_id: target.clone(),
1469 transport: transport.clone(),
1470 message_preview: event.message_preview.clone(),
1471 thread_id: event.details.get("thread_id").cloned(),
1472 channel: event.details.get("channel").cloned(),
1473 delivered_at: event.occurred_at.clone(),
1474 updated_at: event.occurred_at.clone(),
1475 acked_by: None,
1476 };
1477 index_by_message_id.insert(delivery.message_id.clone(), statuses.len());
1478 statuses.push(delivery);
1479 }
1480 }
1481 "drain" if event.status == "drained" => {
1482 let Some(target) = event.target_agent_ids.first() else {
1483 continue;
1484 };
1485 let drained_count = event
1486 .details
1487 .get("drained_count")
1488 .and_then(|value| value.parse::<usize>().ok())
1489 .unwrap_or_default();
1490 if drained_count == 0 {
1491 continue;
1492 }
1493 let mut remaining = drained_count;
1494 for status in statuses.iter_mut() {
1495 if remaining == 0 {
1496 break;
1497 }
1498 if status.target_agent_id == *target && status.status == "pending" {
1499 status.status = "drained".to_string();
1500 status.updated_at = event.occurred_at.clone();
1501 remaining -= 1;
1502 }
1503 }
1504 }
1505 "ack" if event.status == "acked" => {
1506 let Some(message_id) = event.details.get("delivery_event_id") else {
1507 continue;
1508 };
1509 let Some(index) = index_by_message_id.get(message_id).copied() else {
1510 continue;
1511 };
1512 let status = &mut statuses[index];
1513 status.status = "acked".to_string();
1514 status.updated_at = event.occurred_at.clone();
1515 status.acked_by = event.actor_agent_id.clone();
1516 }
1517 _ => {}
1518 }
1519 }
1520
1521 if let Some(agent_id) = agent_id {
1522 statuses.retain(|status| status.target_agent_id == agent_id);
1523 }
1524 Ok(statuses)
1525}
1526
1527pub fn ack_delivery(
1528 mempal_home: &Path,
1529 cwd: &Path,
1530 agent_id: &str,
1531 message_id: &str,
1532) -> Result<DeliveryStatus, BusError> {
1533 validate_agent_id(agent_id)?;
1534 let registry = load_registry(mempal_home, cwd)?;
1535 if !registry.agents.contains_key(agent_id) {
1536 return Err(BusError::UnknownAgent(agent_id.to_string()));
1537 }
1538
1539 let deliveries = list_delivery_statuses(mempal_home, cwd, None)?;
1540 let delivery = deliveries
1541 .iter()
1542 .find(|status| status.message_id == message_id)
1543 .ok_or_else(|| BusError::UnknownDelivery(message_id.to_string()))?;
1544 if delivery.target_agent_id != agent_id {
1545 return Err(BusError::DeliveryTargetMismatch {
1546 message_id: message_id.to_string(),
1547 target_agent_id: delivery.target_agent_id.clone(),
1548 agent_id: agent_id.to_string(),
1549 });
1550 }
1551 if delivery.status == "failed" {
1552 return Err(BusError::CannotAckFailed(message_id.to_string()));
1553 }
1554 if delivery.status != "acked" {
1555 let mut details = BTreeMap::new();
1556 details.insert("delivery_event_id".to_string(), message_id.to_string());
1557 append_bus_event(
1558 mempal_home,
1559 cwd,
1560 "ack",
1561 "acked",
1562 Some(agent_id.to_string()),
1563 vec![agent_id.to_string()],
1564 Some(delivery.transport.clone()),
1565 None,
1566 details,
1567 )?;
1568 }
1569
1570 list_delivery_statuses(mempal_home, cwd, Some(agent_id))?
1571 .into_iter()
1572 .find(|status| status.message_id == message_id)
1573 .ok_or_else(|| BusError::UnknownDelivery(message_id.to_string()))
1574}
1575
1576pub fn format_agent_plain(agent_id: &str, messages: &[InboxMessage]) -> String {
1577 if messages.is_empty() {
1578 return String::new();
1579 }
1580 let mut out = format!(
1581 "[Multi-agent cowork inbox for {} ({} message{} since last check):]\n",
1582 agent_id,
1583 messages.len(),
1584 if messages.len() == 1 { "" } else { "s" }
1585 );
1586 for msg in messages {
1587 let mut metadata = Vec::new();
1588 if let Some(thread_id) = &msg.thread_id {
1589 metadata.push(format!("thread={thread_id}"));
1590 }
1591 if let Some(channel) = &msg.channel {
1592 metadata.push(format!("channel={channel}"));
1593 }
1594 let metadata = if metadata.is_empty() {
1595 String::new()
1596 } else {
1597 format!(" [{}]", metadata.join(" "))
1598 };
1599 out.push_str(&format!(
1600 "- {} from {}{}: {}\n",
1601 msg.pushed_at, msg.from, metadata, msg.content
1602 ));
1603 }
1604 out.push_str("[End multi-agent cowork inbox]\n");
1605 out
1606}
1607
1608pub fn format_events_plain(events: &[BusEvent]) -> String {
1609 let mut out = String::new();
1610 for event in events {
1611 let actor = event.actor_agent_id.as_deref().unwrap_or("-");
1612 let targets = if event.target_agent_ids.is_empty() {
1613 "-".to_string()
1614 } else {
1615 event.target_agent_ids.join(",")
1616 };
1617 let transport = event.transport.as_deref().unwrap_or("-");
1618 let preview = event.message_preview.as_deref().unwrap_or("-");
1619 let details = format_details(&event.details);
1620 out.push_str(&format!(
1621 "{} {} type={} status={} actor={} targets={} transport={} preview={} details={}\n",
1622 event.event_id,
1623 event.occurred_at,
1624 event.event_type,
1625 event.status,
1626 actor,
1627 targets,
1628 transport,
1629 preview,
1630 details
1631 ));
1632 }
1633 out
1634}
1635
1636pub fn format_delivery_statuses_plain(deliveries: &[DeliveryStatus]) -> String {
1637 let mut out = String::new();
1638 for delivery in deliveries {
1639 let preview = delivery.message_preview.as_deref().unwrap_or("-");
1640 let thread_id = delivery.thread_id.as_deref().unwrap_or("-");
1641 let channel = delivery.channel.as_deref().unwrap_or("-");
1642 out.push_str(&format!(
1643 "{} {} status={} from={} target={} transport={} thread={} channel={} preview={}\n",
1644 delivery.message_id,
1645 delivery.updated_at,
1646 delivery.status,
1647 delivery.from,
1648 delivery.target_agent_id,
1649 delivery.transport,
1650 thread_id,
1651 channel,
1652 preview
1653 ));
1654 }
1655 out
1656}
1657
1658pub fn format_doctor_plain(report: &DoctorReport) -> String {
1659 let mut out = String::new();
1660 out.push_str(&format!(
1661 "status={} agents={} channels={} sessions={} stale_agents={} never_seen_agents={} pending_deliveries={}\n",
1662 report.status,
1663 report.agent_count,
1664 report.channel_count,
1665 report.session_count,
1666 report.stale_agents,
1667 report.never_seen_agents,
1668 report.pending_deliveries
1669 ));
1670 for warning in &report.warnings {
1671 out.push_str(&format!("warning: {warning}\n"));
1672 }
1673 for probe in &report.tmux {
1674 out.push_str(&format!(
1675 "tmux agent={} target={} status={} detail={}\n",
1676 probe.agent_id,
1677 probe.tmux_target,
1678 probe.status,
1679 probe.detail.as_deref().unwrap_or("-")
1680 ));
1681 }
1682 out
1683}
1684
1685pub fn format_sessions_plain(sessions: &[TeamSession]) -> String {
1686 if sessions.is_empty() {
1687 return "no sessions\n".to_string();
1688 }
1689 let mut out = String::new();
1690 for session in sessions {
1691 out.push_str(&format!(
1692 "{} status={} title={} agents={} channels={} thread={} updated_at={}\n",
1693 session.session_id,
1694 session.status,
1695 session.title,
1696 session.agents.join(","),
1697 if session.channels.is_empty() {
1698 "-".to_string()
1699 } else {
1700 session.channels.join(",")
1701 },
1702 session.thread_id.as_deref().unwrap_or("-"),
1703 session.updated_at
1704 ));
1705 if let Some(goal) = &session.goal {
1706 out.push_str(&format!(" goal={goal}\n"));
1707 }
1708 }
1709 out
1710}
1711
1712pub fn format_handoff_plain(summary: &HandoffSummary) -> String {
1713 let mut out = String::new();
1714 out.push_str("Cowork Handoff Summary\n");
1715 out.push_str(&format!(
1716 "filters thread={} channel={} session={} limit={}\n\n",
1717 summary.filters.thread_id.as_deref().unwrap_or("-"),
1718 summary.filters.channel.as_deref().unwrap_or("-"),
1719 summary.filters.session_id.as_deref().unwrap_or("-"),
1720 summary.filters.limit
1721 ));
1722
1723 out.push_str("Active sessions\n");
1724 let active_sessions = summary
1725 .sessions
1726 .iter()
1727 .filter(|session| session.status == "active")
1728 .collect::<Vec<_>>();
1729 if active_sessions.is_empty() {
1730 out.push_str("- none\n");
1731 } else {
1732 for session in active_sessions {
1733 out.push_str(&format!(
1734 "- {} [{}] agents={} thread={} goal={}\n",
1735 session.session_id,
1736 session.title,
1737 session.agents.join(","),
1738 session.thread_id.as_deref().unwrap_or("-"),
1739 session.goal.as_deref().unwrap_or("-")
1740 ));
1741 }
1742 }
1743
1744 out.push_str("\nAgents\n");
1745 if summary.agents.is_empty() {
1746 out.push_str("- none\n");
1747 } else {
1748 for agent in &summary.agents {
1749 out.push_str(&format!(
1750 "- {} tool={} presence={} pending={}\n",
1751 agent.agent_id, agent.tool, agent.presence, agent.pending_count
1752 ));
1753 }
1754 }
1755
1756 out.push_str("\nPending deliveries\n");
1757 if summary.pending_deliveries.is_empty() {
1758 out.push_str("- none\n");
1759 } else {
1760 for delivery in &summary.pending_deliveries {
1761 out.push_str(&format!(
1762 "- {} from={} target={} thread={} channel={} preview={}\n",
1763 delivery.message_id,
1764 delivery.from,
1765 delivery.target_agent_id,
1766 delivery.thread_id.as_deref().unwrap_or("-"),
1767 delivery.channel.as_deref().unwrap_or("-"),
1768 delivery.message_preview.as_deref().unwrap_or("-")
1769 ));
1770 }
1771 }
1772
1773 out.push_str("\nRecent events\n");
1774 if summary.recent_events.is_empty() {
1775 out.push_str("- none\n");
1776 } else {
1777 for event in &summary.recent_events {
1778 out.push_str(&format!(
1779 "- {} type={} status={} actor={} targets={} details={}\n",
1780 event.event_id,
1781 event.event_type,
1782 event.status,
1783 event.actor_agent_id.as_deref().unwrap_or("-"),
1784 event.target_agent_ids.join(","),
1785 format_details(&event.details)
1786 ));
1787 }
1788 }
1789 out
1790}
1791
1792pub fn format_capture_plain(report: &CoworkCaptureReport) -> String {
1793 format!(
1794 "writes={} drawer_id={} wing={} room={} source={}\n",
1795 report.writes,
1796 report.drawer_id.as_deref().unwrap_or("-"),
1797 report.wing,
1798 report.room.as_deref().unwrap_or("-"),
1799 report.source
1800 )
1801}
1802
1803fn append_drain_event(
1804 mempal_home: &Path,
1805 cwd: &Path,
1806 agent_id: &str,
1807 drained_count: usize,
1808 drained_bytes: u64,
1809) -> Result<BusEvent, BusError> {
1810 let mut details = BTreeMap::new();
1811 details.insert("drained_count".to_string(), drained_count.to_string());
1812 details.insert("drained_bytes".to_string(), drained_bytes.to_string());
1813 append_bus_event(
1814 mempal_home,
1815 cwd,
1816 "drain",
1817 "drained",
1818 Some(agent_id.to_string()),
1819 vec![agent_id.to_string()],
1820 Some("inbox".to_string()),
1821 None,
1822 details,
1823 )
1824}
1825
1826#[allow(clippy::too_many_arguments)]
1827fn append_bus_event(
1828 mempal_home: &Path,
1829 cwd: &Path,
1830 event_type: &str,
1831 status: &str,
1832 actor_agent_id: Option<String>,
1833 target_agent_ids: Vec<String>,
1834 transport: Option<String>,
1835 message_preview: Option<String>,
1836 details: BTreeMap<String, String>,
1837) -> Result<BusEvent, BusError> {
1838 use std::io::Write;
1839
1840 let event = BusEvent {
1841 event_id: new_event_id(),
1842 occurred_at: format_rfc3339(SystemTime::now()),
1843 event_type: event_type.to_string(),
1844 status: status.to_string(),
1845 actor_agent_id,
1846 target_agent_ids,
1847 transport,
1848 message_preview,
1849 details,
1850 };
1851 let path = events_path(mempal_home, cwd)?;
1852 if let Some(parent) = path.parent() {
1853 std::fs::create_dir_all(parent)?;
1854 }
1855 let mut file = std::fs::OpenOptions::new()
1856 .create(true)
1857 .append(true)
1858 .open(path)?;
1859 writeln!(file, "{}", serde_json::to_string(&event)?)?;
1860 file.flush()?;
1861 Ok(event)
1862}
1863
1864fn new_event_id() -> String {
1865 let nanos = SystemTime::now()
1866 .duration_since(UNIX_EPOCH)
1867 .map(|duration| duration.as_nanos())
1868 .unwrap_or_default();
1869 let seq = BUS_EVENT_COUNTER.fetch_add(1, Ordering::Relaxed);
1870 format!("evt-{nanos}-{seq}")
1871}
1872
1873fn message_preview(content: &str) -> String {
1874 let mut preview: String = content
1875 .chars()
1876 .take(MAX_EVENT_MESSAGE_PREVIEW_CHARS)
1877 .collect();
1878 if content.chars().count() > MAX_EVENT_MESSAGE_PREVIEW_CHARS {
1879 preview.push_str("...");
1880 }
1881 preview
1882}
1883
1884fn add_optional_detail(details: &mut BTreeMap<String, String>, key: &str, value: Option<&str>) {
1885 if let Some(value) = value {
1886 details.insert(key.to_string(), value.to_string());
1887 }
1888}
1889
1890fn format_details(details: &BTreeMap<String, String>) -> String {
1891 if details.is_empty() {
1892 return "-".to_string();
1893 }
1894 details
1895 .iter()
1896 .map(|(key, value)| format!("{key}={value}"))
1897 .collect::<Vec<_>>()
1898 .join(",")
1899}
1900
1901fn presence_for(last_seen_at: Option<&str>, now_seconds: i64) -> String {
1902 let Some(last_seen_at) = last_seen_at else {
1903 return "never_seen".to_string();
1904 };
1905 let Some(last_seen_seconds) = parse_rfc3339(last_seen_at) else {
1906 return "stale".to_string();
1907 };
1908 if now_seconds.saturating_sub(last_seen_seconds) <= PRESENCE_STALE_AFTER_SECONDS {
1909 "online".to_string()
1910 } else {
1911 "stale".to_string()
1912 }
1913}
1914
1915fn current_unix_seconds() -> i64 {
1916 SystemTime::now()
1917 .duration_since(UNIX_EPOCH)
1918 .map(|duration| duration.as_secs() as i64)
1919 .unwrap_or_default()
1920}
1921
1922fn read_inbox_stats(path: &Path) -> Result<(usize, u64, Vec<InboxMessage>), BusError> {
1923 if !path.exists() {
1924 return Ok((0, 0, Vec::new()));
1925 }
1926 let raw = std::fs::read_to_string(path)?;
1927 let mut count = 0;
1928 let mut preview = Vec::new();
1929 for line in raw.lines() {
1930 let trimmed = line.trim();
1931 if trimmed.is_empty() {
1932 continue;
1933 }
1934 count += 1;
1935 if preview.len() < 3 {
1936 if let Ok(msg) = serde_json::from_str::<InboxMessage>(trimmed) {
1937 preview.push(msg);
1938 }
1939 }
1940 }
1941 Ok((count, raw.len() as u64, preview))
1942}