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 };
1278 let line = serde_json::to_string(&msg)?;
1279 let prospective_count = existing_count + 1;
1280 let prospective_bytes = existing_bytes.saturating_add(line.len() as u64 + 1);
1281 if prospective_count > MAX_PENDING_MESSAGES || prospective_bytes > MAX_TOTAL_INBOX_BYTES {
1282 return Err(BusError::InboxFull {
1283 current_count: existing_count,
1284 current_bytes: existing_bytes,
1285 });
1286 }
1287
1288 let mut file = std::fs::OpenOptions::new()
1289 .create(true)
1290 .append(true)
1291 .open(&path)?;
1292 writeln!(file, "{line}")?;
1293 file.flush()?;
1294 let size = std::fs::metadata(&path)?.len();
1295 Ok((path, size))
1296}
1297
1298fn send_tmux(from: &str, target: &str, tmux_target: &str, content: &str) -> Result<(), BusError> {
1299 let envelope = format!("[mempal bus from {from} to {target}] {content}");
1300 let status = std::process::Command::new("tmux")
1301 .args(["send-keys", "-t", tmux_target, "--", &envelope, "Enter"])
1302 .status()?;
1303 if status.success() {
1304 Ok(())
1305 } else {
1306 Err(BusError::TmuxFailed(
1307 status
1308 .code()
1309 .map(|code| code.to_string())
1310 .unwrap_or_else(|| "terminated by signal".to_string()),
1311 ))
1312 }
1313}
1314
1315fn capture_tmux(tmux_target: &str, lines: usize) -> Result<String, BusError> {
1316 let start = format!("-{lines}");
1317 let output = std::process::Command::new("tmux")
1318 .args(["capture-pane", "-t", tmux_target, "-p", "-S", &start])
1319 .output()?;
1320 if output.status.success() {
1321 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1322 } else {
1323 Err(BusError::TmuxCaptureFailed(
1324 output
1325 .status
1326 .code()
1327 .map(|code| code.to_string())
1328 .unwrap_or_else(|| "terminated by signal".to_string()),
1329 ))
1330 }
1331}
1332
1333fn probe_tmux_target(tmux_target: &str) -> TmuxProbeReport {
1334 match std::process::Command::new("tmux")
1335 .args(["has-session", "-t", tmux_target])
1336 .status()
1337 {
1338 Ok(status) if status.success() => TmuxProbeReport {
1339 agent_id: String::new(),
1340 tmux_target: tmux_target.to_string(),
1341 status: "ok".to_string(),
1342 detail: None,
1343 },
1344 Ok(status) => TmuxProbeReport {
1345 agent_id: String::new(),
1346 tmux_target: tmux_target.to_string(),
1347 status: "failed".to_string(),
1348 detail: Some(
1349 status
1350 .code()
1351 .map(|code| code.to_string())
1352 .unwrap_or_else(|| "terminated by signal".to_string()),
1353 ),
1354 },
1355 Err(error) => TmuxProbeReport {
1356 agent_id: String::new(),
1357 tmux_target: tmux_target.to_string(),
1358 status: "failed".to_string(),
1359 detail: Some(error.to_string()),
1360 },
1361 }
1362}
1363
1364fn capture_id() -> String {
1365 let nanos = SystemTime::now()
1366 .duration_since(UNIX_EPOCH)
1367 .map(|duration| duration.as_nanos())
1368 .unwrap_or_default();
1369 let seq = BUS_EVENT_COUNTER.fetch_add(1, Ordering::Relaxed);
1370 format!("cowork-capture-{nanos}-{seq}")
1371}
1372
1373pub fn drain_agent(
1374 mempal_home: &Path,
1375 cwd: &Path,
1376 agent_id: &str,
1377) -> Result<Vec<InboxMessage>, BusError> {
1378 validate_agent_id(agent_id)?;
1379 let registry = load_registry(mempal_home, cwd)?;
1380 if !registry.agents.contains_key(agent_id) {
1381 return Err(BusError::UnknownAgent(agent_id.to_string()));
1382 }
1383
1384 let path = agent_inbox_path(mempal_home, cwd, agent_id)?;
1385 let draining = path.with_extension("draining");
1386 match std::fs::rename(&path, &draining) {
1387 Ok(_) => {}
1388 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1389 append_drain_event(mempal_home, cwd, agent_id, 0, 0)?;
1390 return Ok(Vec::new());
1391 }
1392 Err(e) => return Err(e.into()),
1393 }
1394
1395 let raw = std::fs::read_to_string(&draining)?;
1396 let mut messages = Vec::new();
1397 for line in raw.lines() {
1398 let trimmed = line.trim();
1399 if trimmed.is_empty() {
1400 continue;
1401 }
1402 if let Ok(msg) = serde_json::from_str::<InboxMessage>(trimmed) {
1403 messages.push(msg);
1404 }
1405 }
1406 let _ = std::fs::remove_file(&draining);
1407 append_drain_event(mempal_home, cwd, agent_id, messages.len(), raw.len() as u64)?;
1408 Ok(messages)
1409}
1410
1411pub fn list_events(
1412 mempal_home: &Path,
1413 cwd: &Path,
1414 limit: Option<usize>,
1415) -> Result<Vec<BusEvent>, BusError> {
1416 let path = events_path(mempal_home, cwd)?;
1417 if !path.exists() {
1418 return Ok(Vec::new());
1419 }
1420
1421 let raw = std::fs::read_to_string(path)?;
1422 let mut events = Vec::new();
1423 for line in raw.lines() {
1424 let trimmed = line.trim();
1425 if trimmed.is_empty() {
1426 continue;
1427 }
1428 events.push(serde_json::from_str::<BusEvent>(trimmed)?);
1429 }
1430
1431 if let Some(limit) = limit
1432 && events.len() > limit
1433 {
1434 events.drain(0..events.len() - limit);
1435 }
1436 Ok(events)
1437}
1438
1439pub fn list_delivery_statuses(
1440 mempal_home: &Path,
1441 cwd: &Path,
1442 agent_id: Option<&str>,
1443) -> Result<Vec<DeliveryStatus>, BusError> {
1444 if let Some(agent_id) = agent_id {
1445 validate_agent_id(agent_id)?;
1446 }
1447 let events = list_events(mempal_home, cwd, None)?;
1448 let mut statuses = Vec::<DeliveryStatus>::new();
1449 let mut index_by_message_id = BTreeMap::<String, usize>::new();
1450
1451 for event in events {
1452 match event.event_type.as_str() {
1453 "send" | "broadcast" if event.status == "delivered" || event.status == "failed" => {
1454 let from = event.actor_agent_id.clone().unwrap_or_default();
1455 let transport = event.transport.clone().unwrap_or_default();
1456 for target in &event.target_agent_ids {
1457 let status = if event.status == "failed" {
1458 "failed"
1459 } else {
1460 "pending"
1461 };
1462 let delivery = DeliveryStatus {
1463 message_id: event.event_id.clone(),
1464 event_type: event.event_type.clone(),
1465 status: status.to_string(),
1466 from: from.clone(),
1467 target_agent_id: target.clone(),
1468 transport: transport.clone(),
1469 message_preview: event.message_preview.clone(),
1470 thread_id: event.details.get("thread_id").cloned(),
1471 channel: event.details.get("channel").cloned(),
1472 delivered_at: event.occurred_at.clone(),
1473 updated_at: event.occurred_at.clone(),
1474 acked_by: None,
1475 };
1476 index_by_message_id.insert(delivery.message_id.clone(), statuses.len());
1477 statuses.push(delivery);
1478 }
1479 }
1480 "drain" if event.status == "drained" => {
1481 let Some(target) = event.target_agent_ids.first() else {
1482 continue;
1483 };
1484 let drained_count = event
1485 .details
1486 .get("drained_count")
1487 .and_then(|value| value.parse::<usize>().ok())
1488 .unwrap_or_default();
1489 if drained_count == 0 {
1490 continue;
1491 }
1492 let mut remaining = drained_count;
1493 for status in statuses.iter_mut() {
1494 if remaining == 0 {
1495 break;
1496 }
1497 if status.target_agent_id == *target && status.status == "pending" {
1498 status.status = "drained".to_string();
1499 status.updated_at = event.occurred_at.clone();
1500 remaining -= 1;
1501 }
1502 }
1503 }
1504 "ack" if event.status == "acked" => {
1505 let Some(message_id) = event.details.get("delivery_event_id") else {
1506 continue;
1507 };
1508 let Some(index) = index_by_message_id.get(message_id).copied() else {
1509 continue;
1510 };
1511 let status = &mut statuses[index];
1512 status.status = "acked".to_string();
1513 status.updated_at = event.occurred_at.clone();
1514 status.acked_by = event.actor_agent_id.clone();
1515 }
1516 _ => {}
1517 }
1518 }
1519
1520 if let Some(agent_id) = agent_id {
1521 statuses.retain(|status| status.target_agent_id == agent_id);
1522 }
1523 Ok(statuses)
1524}
1525
1526pub fn ack_delivery(
1527 mempal_home: &Path,
1528 cwd: &Path,
1529 agent_id: &str,
1530 message_id: &str,
1531) -> Result<DeliveryStatus, BusError> {
1532 validate_agent_id(agent_id)?;
1533 let registry = load_registry(mempal_home, cwd)?;
1534 if !registry.agents.contains_key(agent_id) {
1535 return Err(BusError::UnknownAgent(agent_id.to_string()));
1536 }
1537
1538 let deliveries = list_delivery_statuses(mempal_home, cwd, None)?;
1539 let delivery = deliveries
1540 .iter()
1541 .find(|status| status.message_id == message_id)
1542 .ok_or_else(|| BusError::UnknownDelivery(message_id.to_string()))?;
1543 if delivery.target_agent_id != agent_id {
1544 return Err(BusError::DeliveryTargetMismatch {
1545 message_id: message_id.to_string(),
1546 target_agent_id: delivery.target_agent_id.clone(),
1547 agent_id: agent_id.to_string(),
1548 });
1549 }
1550 if delivery.status == "failed" {
1551 return Err(BusError::CannotAckFailed(message_id.to_string()));
1552 }
1553 if delivery.status != "acked" {
1554 let mut details = BTreeMap::new();
1555 details.insert("delivery_event_id".to_string(), message_id.to_string());
1556 append_bus_event(
1557 mempal_home,
1558 cwd,
1559 "ack",
1560 "acked",
1561 Some(agent_id.to_string()),
1562 vec![agent_id.to_string()],
1563 Some(delivery.transport.clone()),
1564 None,
1565 details,
1566 )?;
1567 }
1568
1569 list_delivery_statuses(mempal_home, cwd, Some(agent_id))?
1570 .into_iter()
1571 .find(|status| status.message_id == message_id)
1572 .ok_or_else(|| BusError::UnknownDelivery(message_id.to_string()))
1573}
1574
1575pub fn format_agent_plain(agent_id: &str, messages: &[InboxMessage]) -> String {
1576 if messages.is_empty() {
1577 return String::new();
1578 }
1579 let mut out = format!(
1580 "[Multi-agent cowork inbox for {} ({} message{} since last check):]\n",
1581 agent_id,
1582 messages.len(),
1583 if messages.len() == 1 { "" } else { "s" }
1584 );
1585 for msg in messages {
1586 let mut metadata = Vec::new();
1587 if let Some(thread_id) = &msg.thread_id {
1588 metadata.push(format!("thread={thread_id}"));
1589 }
1590 if let Some(channel) = &msg.channel {
1591 metadata.push(format!("channel={channel}"));
1592 }
1593 let metadata = if metadata.is_empty() {
1594 String::new()
1595 } else {
1596 format!(" [{}]", metadata.join(" "))
1597 };
1598 out.push_str(&format!(
1599 "- {} from {}{}: {}\n",
1600 msg.pushed_at, msg.from, metadata, msg.content
1601 ));
1602 }
1603 out.push_str("[End multi-agent cowork inbox]\n");
1604 out
1605}
1606
1607pub fn format_events_plain(events: &[BusEvent]) -> String {
1608 let mut out = String::new();
1609 for event in events {
1610 let actor = event.actor_agent_id.as_deref().unwrap_or("-");
1611 let targets = if event.target_agent_ids.is_empty() {
1612 "-".to_string()
1613 } else {
1614 event.target_agent_ids.join(",")
1615 };
1616 let transport = event.transport.as_deref().unwrap_or("-");
1617 let preview = event.message_preview.as_deref().unwrap_or("-");
1618 let details = format_details(&event.details);
1619 out.push_str(&format!(
1620 "{} {} type={} status={} actor={} targets={} transport={} preview={} details={}\n",
1621 event.event_id,
1622 event.occurred_at,
1623 event.event_type,
1624 event.status,
1625 actor,
1626 targets,
1627 transport,
1628 preview,
1629 details
1630 ));
1631 }
1632 out
1633}
1634
1635pub fn format_delivery_statuses_plain(deliveries: &[DeliveryStatus]) -> String {
1636 let mut out = String::new();
1637 for delivery in deliveries {
1638 let preview = delivery.message_preview.as_deref().unwrap_or("-");
1639 let thread_id = delivery.thread_id.as_deref().unwrap_or("-");
1640 let channel = delivery.channel.as_deref().unwrap_or("-");
1641 out.push_str(&format!(
1642 "{} {} status={} from={} target={} transport={} thread={} channel={} preview={}\n",
1643 delivery.message_id,
1644 delivery.updated_at,
1645 delivery.status,
1646 delivery.from,
1647 delivery.target_agent_id,
1648 delivery.transport,
1649 thread_id,
1650 channel,
1651 preview
1652 ));
1653 }
1654 out
1655}
1656
1657pub fn format_doctor_plain(report: &DoctorReport) -> String {
1658 let mut out = String::new();
1659 out.push_str(&format!(
1660 "status={} agents={} channels={} sessions={} stale_agents={} never_seen_agents={} pending_deliveries={}\n",
1661 report.status,
1662 report.agent_count,
1663 report.channel_count,
1664 report.session_count,
1665 report.stale_agents,
1666 report.never_seen_agents,
1667 report.pending_deliveries
1668 ));
1669 for warning in &report.warnings {
1670 out.push_str(&format!("warning: {warning}\n"));
1671 }
1672 for probe in &report.tmux {
1673 out.push_str(&format!(
1674 "tmux agent={} target={} status={} detail={}\n",
1675 probe.agent_id,
1676 probe.tmux_target,
1677 probe.status,
1678 probe.detail.as_deref().unwrap_or("-")
1679 ));
1680 }
1681 out
1682}
1683
1684pub fn format_sessions_plain(sessions: &[TeamSession]) -> String {
1685 if sessions.is_empty() {
1686 return "no sessions\n".to_string();
1687 }
1688 let mut out = String::new();
1689 for session in sessions {
1690 out.push_str(&format!(
1691 "{} status={} title={} agents={} channels={} thread={} updated_at={}\n",
1692 session.session_id,
1693 session.status,
1694 session.title,
1695 session.agents.join(","),
1696 if session.channels.is_empty() {
1697 "-".to_string()
1698 } else {
1699 session.channels.join(",")
1700 },
1701 session.thread_id.as_deref().unwrap_or("-"),
1702 session.updated_at
1703 ));
1704 if let Some(goal) = &session.goal {
1705 out.push_str(&format!(" goal={goal}\n"));
1706 }
1707 }
1708 out
1709}
1710
1711pub fn format_handoff_plain(summary: &HandoffSummary) -> String {
1712 let mut out = String::new();
1713 out.push_str("Cowork Handoff Summary\n");
1714 out.push_str(&format!(
1715 "filters thread={} channel={} session={} limit={}\n\n",
1716 summary.filters.thread_id.as_deref().unwrap_or("-"),
1717 summary.filters.channel.as_deref().unwrap_or("-"),
1718 summary.filters.session_id.as_deref().unwrap_or("-"),
1719 summary.filters.limit
1720 ));
1721
1722 out.push_str("Active sessions\n");
1723 let active_sessions = summary
1724 .sessions
1725 .iter()
1726 .filter(|session| session.status == "active")
1727 .collect::<Vec<_>>();
1728 if active_sessions.is_empty() {
1729 out.push_str("- none\n");
1730 } else {
1731 for session in active_sessions {
1732 out.push_str(&format!(
1733 "- {} [{}] agents={} thread={} goal={}\n",
1734 session.session_id,
1735 session.title,
1736 session.agents.join(","),
1737 session.thread_id.as_deref().unwrap_or("-"),
1738 session.goal.as_deref().unwrap_or("-")
1739 ));
1740 }
1741 }
1742
1743 out.push_str("\nAgents\n");
1744 if summary.agents.is_empty() {
1745 out.push_str("- none\n");
1746 } else {
1747 for agent in &summary.agents {
1748 out.push_str(&format!(
1749 "- {} tool={} presence={} pending={}\n",
1750 agent.agent_id, agent.tool, agent.presence, agent.pending_count
1751 ));
1752 }
1753 }
1754
1755 out.push_str("\nPending deliveries\n");
1756 if summary.pending_deliveries.is_empty() {
1757 out.push_str("- none\n");
1758 } else {
1759 for delivery in &summary.pending_deliveries {
1760 out.push_str(&format!(
1761 "- {} from={} target={} thread={} channel={} preview={}\n",
1762 delivery.message_id,
1763 delivery.from,
1764 delivery.target_agent_id,
1765 delivery.thread_id.as_deref().unwrap_or("-"),
1766 delivery.channel.as_deref().unwrap_or("-"),
1767 delivery.message_preview.as_deref().unwrap_or("-")
1768 ));
1769 }
1770 }
1771
1772 out.push_str("\nRecent events\n");
1773 if summary.recent_events.is_empty() {
1774 out.push_str("- none\n");
1775 } else {
1776 for event in &summary.recent_events {
1777 out.push_str(&format!(
1778 "- {} type={} status={} actor={} targets={} details={}\n",
1779 event.event_id,
1780 event.event_type,
1781 event.status,
1782 event.actor_agent_id.as_deref().unwrap_or("-"),
1783 event.target_agent_ids.join(","),
1784 format_details(&event.details)
1785 ));
1786 }
1787 }
1788 out
1789}
1790
1791pub fn format_capture_plain(report: &CoworkCaptureReport) -> String {
1792 format!(
1793 "writes={} drawer_id={} wing={} room={} source={}\n",
1794 report.writes,
1795 report.drawer_id.as_deref().unwrap_or("-"),
1796 report.wing,
1797 report.room.as_deref().unwrap_or("-"),
1798 report.source
1799 )
1800}
1801
1802fn append_drain_event(
1803 mempal_home: &Path,
1804 cwd: &Path,
1805 agent_id: &str,
1806 drained_count: usize,
1807 drained_bytes: u64,
1808) -> Result<BusEvent, BusError> {
1809 let mut details = BTreeMap::new();
1810 details.insert("drained_count".to_string(), drained_count.to_string());
1811 details.insert("drained_bytes".to_string(), drained_bytes.to_string());
1812 append_bus_event(
1813 mempal_home,
1814 cwd,
1815 "drain",
1816 "drained",
1817 Some(agent_id.to_string()),
1818 vec![agent_id.to_string()],
1819 Some("inbox".to_string()),
1820 None,
1821 details,
1822 )
1823}
1824
1825#[allow(clippy::too_many_arguments)]
1826fn append_bus_event(
1827 mempal_home: &Path,
1828 cwd: &Path,
1829 event_type: &str,
1830 status: &str,
1831 actor_agent_id: Option<String>,
1832 target_agent_ids: Vec<String>,
1833 transport: Option<String>,
1834 message_preview: Option<String>,
1835 details: BTreeMap<String, String>,
1836) -> Result<BusEvent, BusError> {
1837 use std::io::Write;
1838
1839 let event = BusEvent {
1840 event_id: new_event_id(),
1841 occurred_at: format_rfc3339(SystemTime::now()),
1842 event_type: event_type.to_string(),
1843 status: status.to_string(),
1844 actor_agent_id,
1845 target_agent_ids,
1846 transport,
1847 message_preview,
1848 details,
1849 };
1850 let path = events_path(mempal_home, cwd)?;
1851 if let Some(parent) = path.parent() {
1852 std::fs::create_dir_all(parent)?;
1853 }
1854 let mut file = std::fs::OpenOptions::new()
1855 .create(true)
1856 .append(true)
1857 .open(path)?;
1858 writeln!(file, "{}", serde_json::to_string(&event)?)?;
1859 file.flush()?;
1860 Ok(event)
1861}
1862
1863fn new_event_id() -> String {
1864 let nanos = SystemTime::now()
1865 .duration_since(UNIX_EPOCH)
1866 .map(|duration| duration.as_nanos())
1867 .unwrap_or_default();
1868 let seq = BUS_EVENT_COUNTER.fetch_add(1, Ordering::Relaxed);
1869 format!("evt-{nanos}-{seq}")
1870}
1871
1872fn message_preview(content: &str) -> String {
1873 let mut preview: String = content
1874 .chars()
1875 .take(MAX_EVENT_MESSAGE_PREVIEW_CHARS)
1876 .collect();
1877 if content.chars().count() > MAX_EVENT_MESSAGE_PREVIEW_CHARS {
1878 preview.push_str("...");
1879 }
1880 preview
1881}
1882
1883fn add_optional_detail(details: &mut BTreeMap<String, String>, key: &str, value: Option<&str>) {
1884 if let Some(value) = value {
1885 details.insert(key.to_string(), value.to_string());
1886 }
1887}
1888
1889fn format_details(details: &BTreeMap<String, String>) -> String {
1890 if details.is_empty() {
1891 return "-".to_string();
1892 }
1893 details
1894 .iter()
1895 .map(|(key, value)| format!("{key}={value}"))
1896 .collect::<Vec<_>>()
1897 .join(",")
1898}
1899
1900fn presence_for(last_seen_at: Option<&str>, now_seconds: i64) -> String {
1901 let Some(last_seen_at) = last_seen_at else {
1902 return "never_seen".to_string();
1903 };
1904 let Some(last_seen_seconds) = parse_rfc3339(last_seen_at) else {
1905 return "stale".to_string();
1906 };
1907 if now_seconds.saturating_sub(last_seen_seconds) <= PRESENCE_STALE_AFTER_SECONDS {
1908 "online".to_string()
1909 } else {
1910 "stale".to_string()
1911 }
1912}
1913
1914fn current_unix_seconds() -> i64 {
1915 SystemTime::now()
1916 .duration_since(UNIX_EPOCH)
1917 .map(|duration| duration.as_secs() as i64)
1918 .unwrap_or_default()
1919}
1920
1921fn read_inbox_stats(path: &Path) -> Result<(usize, u64, Vec<InboxMessage>), BusError> {
1922 if !path.exists() {
1923 return Ok((0, 0, Vec::new()));
1924 }
1925 let raw = std::fs::read_to_string(path)?;
1926 let mut count = 0;
1927 let mut preview = Vec::new();
1928 for line in raw.lines() {
1929 let trimmed = line.trim();
1930 if trimmed.is_empty() {
1931 continue;
1932 }
1933 count += 1;
1934 if preview.len() < 3 {
1935 if let Ok(msg) = serde_json::from_str::<InboxMessage>(trimmed) {
1936 preview.push(msg);
1937 }
1938 }
1939 }
1940 Ok((count, raw.len() as u64, preview))
1941}