Skip to main content

mempal_runtime/cowork/
bus.rs

1//! Multi-agent cowork bus for concrete agent instances.
2//!
3//! P8 routes by tool family (`claude` / `codex`). P84 adds `agent_id`
4//! addressing so multiple instances of the same tool can work in one project
5//! without racing on a shared inbox.
6
7use 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, &registry)?;
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, &registry)?;
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, &registry)?;
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}