Skip to main content

lean_ctx_server/tools/
ctx_agent.rs

1// SPDX-License-Identifier: Apache-2.0
2use lean_ctx::core::a2a::message::{MessagePriority, PrivacyLevel};
3use lean_ctx::core::a2a::task::TaskStore;
4use lean_ctx::core::agents::{
5    AgentDiary, AgentRegistry, AgentStatus, DiaryEntryType, ScratchpadEntry,
6};
7use lean_ctx::core::ocla::capsule::global_capsule_store;
8use lean_ctx::core::ocla::registry::OclaRegistry;
9use lean_ctx::core::ocla::types::{
10    AGENT_ENVELOPE_SCHEMA_VERSION, AgentEnvelope, AgentMessageRequest, OclaRequestContext,
11};
12
13#[path = "ctx_agent_control.rs"]
14mod control;
15#[path = "ctx_agent_lease.rs"]
16mod lease;
17use control::{control_status, lease_resource};
18#[cfg(test)]
19use lease::LeaseSettle;
20
21fn presence_ttl_hours() -> u64 {
22    lean_ctx::core::config::Config::load()
23        .agents
24        .presence_ttl_hours
25}
26
27fn scratchpad_default_ttl_hours() -> u64 {
28    lean_ctx::core::config::Config::load()
29        .agents
30        .scratchpad_default_ttl_hours
31}
32
33/// Every action [`handle`] dispatches, one entry each. The MCP schema's
34/// `action` enum and the unknown-action reply are both built from this list.
35pub(crate) const ACTIONS: &[&str] = &[
36    "register",
37    "list",
38    "post",
39    "read",
40    "wait",
41    "status",
42    "info",
43    "handoff",
44    "sync",
45    "claim",
46    "release",
47    "brief",
48    "return",
49    "export",
50    "diary",
51    "recall_diary",
52    "diaries",
53    "share_knowledge",
54    "receive_knowledge",
55    "poll_events",
56    "control_status",
57    "lease_acquire",
58    "lease_renew",
59    "lease_release",
60    "send",
61    "reply",
62    "inbox",
63];
64
65/// Arguments of the Hive message actions (`send`, `reply`, `inbox`).
66#[derive(Debug, Clone, Default)]
67pub struct MessageArgs {
68    pub message_type: Option<String>,
69    pub text: Option<String>,
70    pub to: Option<String>,
71    pub reply_to: Option<String>,
72    pub references: Vec<String>,
73    /// reply to a handoff: take it (`true`) or decline it.
74    pub accept: Option<bool>,
75}
76
77impl MessageArgs {
78    fn untyped(text: Option<&str>, category: Option<&str>, to: Option<&str>) -> Self {
79        Self {
80            message_type: category.map(str::to_string),
81            text: text.map(str::to_string),
82            to: to.map(str::to_string),
83            ..Self::default()
84        }
85    }
86}
87
88/// `send`: a typed message to the vault bus of the checkout. `reply`:
89/// answers a received message in its thread (type defaults to `answer`).
90/// `inbox`: the unread messages, Hive deliveries fenced as foreign data
91/// ([`lean_ctx_hive::bus::messages_v2`]).
92pub fn handle_message(
93    action: &str,
94    args: &MessageArgs,
95    project_root: &str,
96    current_agent_id: Option<&str>,
97) -> String {
98    use lean_ctx_hive::bus::messages_v2::{Extras, send};
99    let Some(agent) = current_agent_id else {
100        return "Error: agent must be registered first (use action=register)".to_string();
101    };
102    if action == "inbox" {
103        return match read_messages_as(agent, project_root, true) {
104            Ok(text) | Err(text) => text,
105        };
106    }
107    let Some(text) = args.text.as_deref() else {
108        return format!("Error: message is required for {action}");
109    };
110    let reply_to = args.reply_to.as_deref();
111    if action == "reply" && reply_to.is_none() {
112        return "Error: reply_to (the msg_… id of the message answered) is required".to_string();
113    }
114    let message_type = args
115        .message_type
116        .as_deref()
117        .or((action == "reply").then_some("answer"));
118    let extras = Extras {
119        to_worker: args.to.as_deref(),
120        reply_to,
121        accept: args.accept,
122        resource: args.references.first().map(String::as_str),
123        refs: &args.references,
124    };
125    match send(project_root, agent, message_type, text, extras) {
126        Ok(id) => format!(
127            "Queued [{}] {id}; the Hive relay sends it to the vault's agents",
128            message_type.unwrap_or("?")
129        ),
130        Err(error) => format!("Error: {error}"),
131    }
132}
133
134/// `action=read` with a typed outcome: `Ok` is the reply, `Err` is the text
135/// of a failed read — the same text [`handle`] answers. `read` is the one
136/// action of this tool that declares every source
137/// (`CtxAgentTool::publishes_every_source`), so its failure must reach the
138/// registered tool as a failure and never as a reply that could be recorded
139/// as a delivery of the sources captured before it failed.
140pub fn handle_read(
141    message: Option<&str>,
142    current_agent_id: Option<&str>,
143    project_root: &str,
144) -> Result<String, String> {
145    // Same admission as `handle`: unadmitted caller text refuses the call.
146    if let Some(text) = message
147        && let Err(withheld) = lean_ctx::core::context_admission::carrier::admit_for_store(text)
148    {
149        return Err(format!("Error: {}", withheld.message("agent message")));
150    }
151    read_messages(current_agent_id, project_root)
152}
153
154/// The unread messages of `current_agent_id` in `project_root`.
155fn read_messages(current_agent_id: Option<&str>, project_root: &str) -> Result<String, String> {
156    let Some(agent_id) = current_agent_id else {
157        return Err("Error: agent must be registered first (use action=register)".to_string());
158    };
159    let mut text = read_messages_as(agent_id, project_root, true)?;
160    // What other agents declared in the linked emspace space, beside the bus.
161    if let Some(sessions) =
162        lean_ctx::core::providers::emspace::admitted_sessions_block(project_root)
163    {
164        text.push_str(&sessions);
165    }
166    Ok(text)
167}
168
169/// The line `read`/`wait` print once after an expired registration was renewed.
170fn renewed_line(agent_id: &str) -> String {
171    format!(
172        "Note: the registration of {agent_id} had expired and was renewed; retry a claim refused meanwhile.\n"
173    )
174}
175
176/// One `read` answers at most this much message text (at least one
177/// message); the rest stays unread and the answer says how many. Hosts
178/// archive or cut longer tool answers, and the cut messages were lost.
179pub(crate) const READ_PAGE_BYTES: usize = 6_000;
180
181/// One unread message as `read` shows it.
182fn render_message(m: &ScratchpadEntry, project_root: &str) -> String {
183    let age = (chrono::Utc::now() - m.timestamp).num_minutes();
184    // A new delivery: re-authorized under the current policy. One from
185    // another instance carries its sender and kind inside the fence.
186    if lean_ctx_hive::bus::relay_v2::is_inbound(m) {
187        // Text from another Instance: untrusted by origin (pz21 §1.1).
188        lean_ctx::core::context_admission::capture::note_origin(
189            &lean_ctx::core::context_admission::origin::SourceClass::External(
190                lean_ctx::core::context_admission::origin::InputOrigin::new(
191                    lean_ctx::core::context_admission::origin::InputKind::BusMessage,
192                    format!("hive:{}", m.id),
193                ),
194            ),
195        );
196        return format!(
197            "  [hive] ({age}m ago):\n{}\n",
198            m.served_message(Some(project_root))
199        );
200    }
201    format!(
202        "  [{}] from {} ({}m ago): {}\n",
203        m.category,
204        m.from_agent,
205        age,
206        m.served_message(Some(project_root))
207    )
208}
209
210/// The unread messages of `agent_id`, oldest first and at most
211/// `READ_PAGE_BYTES` per call; only the ones shown are marked read.
212/// `own_process`: the call comes from the agent's own session, which renews
213/// its lease (and revives an expired one); `lean-ctx agent wait` reads for
214/// it from another process.
215pub fn read_messages_as(
216    agent_id: &str,
217    project_root: &str,
218    own_process: bool,
219) -> Result<String, String> {
220    let (shown, left) = match AgentRegistry::mutate_locked(|registry| {
221        if own_process {
222            registry.renew_worker(agent_id)?;
223        }
224        registry.read_unread_page(agent_id, project_root, READ_PAGE_BYTES, |m| {
225            render_message(m, project_root)
226        })
227    }) {
228        Ok((_, Ok(page))) => page,
229        Ok((_, Err(error))) => {
230            tracing::warn!("lean-ctx: message read rejected: {error}");
231            return Err(format!("Error: {error}"));
232        }
233        Err(e) => {
234            tracing::warn!(
235                "lean-ctx: failed to persist agent registry (messages may reappear): {e}"
236            );
237            return Err("Error: failed to read messages".to_string());
238        }
239    };
240
241    let mut renewed = if lean_ctx::core::agents::take_renewed(agent_id) {
242        renewed_line(agent_id)
243    } else {
244        String::new()
245    };
246    // An MCP server older than the installed binary misses its fixes.
247    if own_process && let Some(note) = lean_ctx::core::binary_identity::replaced_note() {
248        renewed.insert_str(0, &note);
249    }
250    if shown.is_empty() {
251        return Ok(format!("{renewed}No new messages."));
252    }
253
254    let mut out = format!("{renewed}New messages ({}):\n", shown.len());
255    for line in &shown {
256        out.push_str(line);
257    }
258    if left > 0 {
259        out.push_str(&format!(
260            "{left} more unread (still unread): read again for the next ones.\n"
261        ));
262    }
263    Ok(out)
264}
265
266/// `wait` without `timeout_secs`, and its ceiling.
267pub const WAIT_DEFAULT_SECS: u64 = 600;
268pub const WAIT_MAX_SECS: u64 = 1800;
269
270/// The stable reply of a `wait` that saw no message.
271pub fn wait_timeout_line(secs: u64) -> String {
272    format!("No new messages within {secs}s.")
273}
274
275/// `action=wait`: block until a message for `agent_id` (to it or broadcast,
276/// not its own) is waiting in `project_root`, then answer exactly like
277/// `read`; `Ok(None)` after `timeout_secs` (default 600, at most 1800).
278/// `own_process` as in [`read_messages_as`]: the agent's own session keeps
279/// its lease alive while it waits.
280pub fn wait_messages(
281    agent_id: &str,
282    project_root: &str,
283    timeout_secs: Option<u64>,
284    own_process: bool,
285) -> Result<Option<String>, String> {
286    let secs = timeout_secs.unwrap_or(WAIT_DEFAULT_SECS).min(WAIT_MAX_SECS);
287    if own_process {
288        // Fail fast on an unknown or finished agent instead of waiting.
289        AgentRegistry::renew_worker_persistent(agent_id).map_err(|e| format!("Error: {e}"))?;
290    }
291    let arrived = AgentRegistry::wait_for_unread(
292        agent_id,
293        project_root,
294        std::time::Duration::from_secs(secs),
295        || {
296            if own_process && let Err(e) = AgentRegistry::renew_worker_persistent(agent_id) {
297                tracing::debug!("lean-ctx: wait keepalive for {agent_id}: {e}");
298            }
299        },
300    );
301    if arrived {
302        return read_messages_as(agent_id, project_root, own_process).map(Some);
303    }
304    lean_ctx::core::agents::friction::record_for(
305        lean_ctx::core::agents::friction::WAIT_TIMEOUT,
306        Some(agent_id),
307        Some(project_root),
308    );
309    Ok(None)
310}
311
312/// The text of `wait` for the MCP tool: the messages, or the timeout line
313/// (after the renewal note, when there is one).
314pub fn handle_wait(
315    current_agent_id: Option<&str>,
316    project_root: &str,
317    timeout_secs: Option<u64>,
318) -> Result<String, String> {
319    let Some(agent_id) = current_agent_id else {
320        return Err("Error: agent must be registered first (use action=register)".to_string());
321    };
322    let secs = timeout_secs.unwrap_or(WAIT_DEFAULT_SECS).min(WAIT_MAX_SECS);
323    Ok(
324        match wait_messages(agent_id, project_root, timeout_secs, true)? {
325            Some(text) => text,
326            None if lean_ctx::core::agents::take_renewed(agent_id) => {
327                format!("{}{}", renewed_line(agent_id), wait_timeout_line(secs))
328            }
329            None => wait_timeout_line(secs),
330        },
331    )
332}
333
334/// What `register` stores from the caller's `role` and `name`: `role` is a
335/// role id ([`lean_ctx::core::agents::role_id`]), `name` the display name
336/// (a bounded single line). Free text given as `role` is never stored as a
337/// role; it becomes the display name when no `name` is given. The warnings
338/// say what was not taken as asked.
339pub fn role_and_name(
340    role: Option<&str>,
341    name: Option<&str>,
342) -> (Option<String>, Option<String>, Vec<String>) {
343    let mut warnings = Vec::new();
344    let mut display = None;
345    if let Some(raw) = name {
346        display = lean_ctx::core::activity::session_name(raw);
347        if display.is_none() {
348            warnings.push("warning: name ignored (1-128 characters, one line)".to_owned());
349        }
350    }
351    let role_id = role.and_then(lean_ctx::core::agents::role_id);
352    if let (Some(raw), None) = (role, &role_id) {
353        warnings.push(format!(
354            "warning: unknown_role: {:?} is not a role id (coder, reviewer, tester, …); \
355             pass a display name as name=",
356            raw.chars().take(64).collect::<String>()
357        ));
358        if display.is_none() {
359            display = lean_ctx::core::activity::session_name(raw);
360        }
361    }
362    (role_id, display, warnings)
363}
364
365/// `register`: register (or refresh) this process's presence with `role`
366/// and `name` and answer exactly what was stored. A re-register on the same
367/// presence takes over the new role and name.
368pub fn register(
369    agent_type: Option<&str>,
370    role: Option<&str>,
371    name: Option<&str>,
372    durable_identity_id: Option<&str>,
373    project_root: &str,
374) -> String {
375    let atype = agent_type.unwrap_or("unknown");
376    let (role, name, warnings) = role_and_name(role, name);
377    let stored = AgentRegistry::mutate_locked(|registry| {
378        registry.cleanup_stale(presence_ttl_hours());
379        let agent_id =
380            registry.register(atype, role.as_deref(), project_root, durable_identity_id)?;
381        if let Some(name) = &name {
382            registry.set_display_name(&agent_id, name);
383        }
384        let entry = registry.agents.iter().find(|a| a.agent_id == agent_id);
385        Ok((
386            agent_id.clone(),
387            entry.and_then(|a| a.role.clone()),
388            entry.and_then(|a| a.display_name.clone()),
389        ))
390    })
391    .and_then(|(_, stored)| stored);
392    match stored {
393        Ok((agent_id, role, name)) => {
394            // The relay announces the agent (and its role) now, not on its
395            // next tick: a claim right after register finds it.
396            lean_ctx_hive::bus::presence::request_announce();
397            // Callers take the id from the text after the first ':'; the
398            // warnings and the note come after the stored values.
399            let mut text = format!(
400                "Agent registered: {agent_id} (type: {atype}, role: {}, name: {})",
401                role.as_deref().unwrap_or("none"),
402                name.as_deref().unwrap_or("none"),
403            );
404            for warning in warnings {
405                text.push('\n');
406                text.push_str(&warning);
407            }
408            if let Some(note) = lean_ctx::core::binary_identity::replaced_note() {
409                text.push('\n');
410                text.push_str(note.trim_end());
411            }
412            text
413        }
414        Err(e) => format!("Registration failed: {e}"),
415    }
416}
417
418/// The Queen-network fields of a `post` (QN0): its kind (one of the
419/// fourteen Queen kinds, posted only by the registered Queen), the task it
420/// belongs to and its conversation.
421#[derive(Debug, Clone, Copy, Default)]
422pub struct PostFields<'a> {
423    pub kind: Option<&'a str>,
424    pub task_id: Option<&'a str>,
425    pub thread: Option<&'a str>,
426}
427
428/// A `ctx_agent` call. What Hive answered to this agent's earlier lease
429/// requests (asked off the tool call) comes in front of the answer, except
430/// for `read`, whose answer is a delivery of its sources only.
431#[allow(clippy::too_many_arguments)]
432pub fn handle(
433    action: &str,
434    agent_type: Option<&str>,
435    role: Option<&str>,
436    durable_identity_id: Option<&str>,
437    project_root: &str,
438    current_agent_id: Option<&str>,
439    message: Option<&str>,
440    category: Option<&str>,
441    to_agent: Option<&str>,
442    status: Option<&str>,
443    privacy: Option<&str>,
444    priority: Option<&str>,
445    ttl_hours: Option<u64>,
446    format: Option<&str>,
447    write: bool,
448    filename: Option<&str>,
449    request_id: Option<&str>,
450) -> String {
451    handle_with(
452        action,
453        agent_type,
454        role,
455        durable_identity_id,
456        project_root,
457        current_agent_id,
458        message,
459        category,
460        to_agent,
461        status,
462        privacy,
463        priority,
464        ttl_hours,
465        format,
466        write,
467        filename,
468        request_id,
469        PostFields::default(),
470    )
471}
472
473/// A post's Queen-network fields as the bus stores them: the task id and
474/// the entry metadata (kind, thread). Refused: an unknown kind (with the
475/// list), priority high or critical with a kind other than conflict,
476/// escalate or stop, a malformed task id or thread, and a kind from a sender
477/// that is no registered Queen (`not_queen`) or over its minute's budget
478/// (`rate_limited`).
479fn checked_post_fields(
480    registry: &AgentRegistry,
481    sender: &str,
482    post: PostFields<'_>,
483    priority: &MessagePriority,
484) -> Result<(Option<String>, std::collections::BTreeMap<String, String>), String> {
485    use lean_ctx_hive::bus::queen_kinds::{self, QueenKind, SenderRefusal};
486    let kind = QueenKind::from_input(post.kind)?;
487    if let Some(task_id) = post.task_id {
488        queen_kinds::check_task_id(task_id)?;
489    }
490    if let Some(thread) = post.thread {
491        queen_kinds::check_thread(thread)?;
492    }
493    if let Some(kind) = kind
494        && matches!(priority, MessagePriority::High | MessagePriority::Critical)
495        && !kind.allows_high()
496    {
497        return Err(format!(
498            "priority high is only for kinds conflict, escalate and stop, not {}",
499            kind.as_str()
500        ));
501    }
502    // Every post counts against its sender's budget, as on the CLI; a
503    // kind also needs the registered Queen.
504    let now = chrono::Utc::now().timestamp_millis();
505    queen_kinds::check_sender(registry, sender, kind, now).map_err(|refused| match refused {
506        SenderRefusal::NotQueen(why) | SenderRefusal::RateLimited(why) => why,
507    })?;
508    let mut metadata = std::collections::BTreeMap::new();
509    if let Some(kind) = kind {
510        metadata.insert(queen_kinds::KIND_KEY.to_owned(), kind.as_str().to_owned());
511    }
512    if let Some(thread) = post.thread {
513        metadata.insert(queen_kinds::THREAD_KEY.to_owned(), thread.to_owned());
514    }
515    Ok((post.task_id.map(str::to_owned), metadata))
516}
517
518/// [`handle`] with a post's Queen-network fields.
519#[allow(clippy::too_many_arguments)]
520pub fn handle_with(
521    action: &str,
522    agent_type: Option<&str>,
523    role: Option<&str>,
524    durable_identity_id: Option<&str>,
525    project_root: &str,
526    current_agent_id: Option<&str>,
527    message: Option<&str>,
528    category: Option<&str>,
529    to_agent: Option<&str>,
530    status: Option<&str>,
531    privacy: Option<&str>,
532    priority: Option<&str>,
533    ttl_hours: Option<u64>,
534    format: Option<&str>,
535    write: bool,
536    filename: Option<&str>,
537    request_id: Option<&str>,
538    post: PostFields<'_>,
539) -> String {
540    let earlier = current_agent_id.filter(|_| action != "read").map(|agent| {
541        lean_ctx_hive::background::earlier(&lean_ctx_hive::bus::leases::caller(agent))
542    });
543    let answer = handle_action(
544        action,
545        agent_type,
546        role,
547        durable_identity_id,
548        project_root,
549        current_agent_id,
550        message,
551        category,
552        to_agent,
553        status,
554        privacy,
555        priority,
556        ttl_hours,
557        format,
558        write,
559        filename,
560        request_id,
561        post,
562    );
563    match earlier {
564        Some(earlier) => earlier.present(answer),
565        None => answer,
566    }
567}
568
569#[allow(clippy::too_many_arguments)]
570fn handle_action(
571    action: &str,
572    agent_type: Option<&str>,
573    role: Option<&str>,
574    durable_identity_id: Option<&str>,
575    project_root: &str,
576    current_agent_id: Option<&str>,
577    message: Option<&str>,
578    category: Option<&str>,
579    to_agent: Option<&str>,
580    status: Option<&str>,
581    privacy: Option<&str>,
582    priority: Option<&str>,
583    _ttl_hours: Option<u64>,
584    format: Option<&str>,
585    write: bool,
586    filename: Option<&str>,
587    request_id: Option<&str>,
588    post: PostFields<'_>,
589) -> String {
590    // The caller's text is admitted once, before any action stores, routes,
591    // derives from or echoes it (post, handoff, status, diary, knowledge,
592    // brief). Unadmitted input is never echoed back: a refusal names the typed
593    // reason only. Allowed text is unchanged byte for byte.
594    let admitted_message = match message
595        .map(lean_ctx::core::context_admission::carrier::admit_for_store)
596        .transpose()
597    {
598        Ok(admitted) => admitted,
599        Err(withheld) => return format!("Error: {}", withheld.message("agent message")),
600    };
601    let message = admitted_message.as_deref();
602    match action {
603        "register" => register(agent_type, role, None, durable_identity_id, project_root),
604
605        "list" => {
606            let registry = match AgentRegistry::mutate_locked(|registry| {
607                registry.cleanup_stale(presence_ttl_hours());
608            }) {
609                Ok((registry, ())) => registry,
610                Err(e) => {
611                    tracing::warn!("lean-ctx: failed to persist agent registry: {e}");
612                    AgentRegistry::load_or_create()
613                }
614            };
615
616            let agents = registry.list_active(Some(project_root));
617            let sessions =
618                lean_ctx::core::providers::emspace::admitted_sessions_block(project_root)
619                    .unwrap_or_default();
620            if agents.is_empty() {
621                return format!("No active agents for this project.{sessions}");
622            }
623
624            // V5.3: sessions are listed under the durable worker they attach
625            // to; without an enrolled instance workers have no `wkr_` id.
626            let store = lean_ctx_hive::HiveStore::open().ok();
627            let instance = store
628                .as_ref()
629                .and_then(|s| s.instance().ok().flatten())
630                .map(|record| record.instance_id);
631            let book = match (&store, &instance) {
632                (Some(store), Some(id)) => {
633                    lean_ctx_hive::workers::attach::list(store, id).unwrap_or_default()
634                }
635                _ => Vec::new(),
636            };
637            let mut roster = lean_ctx_hive::workers::roster::render(
638                &agents,
639                instance.as_deref().map(|id| (id, book.as_slice())),
640                chrono::Utc::now(),
641            );
642            if let Some(store) = &store {
643                roster.push_str(&lean_ctx_hive::bus::presence::render_remote(
644                    store.data_dir(),
645                    chrono::Utc::now(),
646                ));
647            }
648            roster.push_str(&sessions);
649            roster
650        }
651
652        "post" => {
653            let Some(msg) = message else {
654                return "Error: message is required for post".to_string();
655            };
656            let cat = category.unwrap_or("status");
657            let from = current_agent_id.unwrap_or("anonymous");
658            let msg_privacy = privacy.map_or(PrivacyLevel::Team, PrivacyLevel::parse_str);
659            let msg_priority = priority.map_or(MessagePriority::Normal, MessagePriority::parse_str);
660            let ttl_hours = _ttl_hours.or(Some(scratchpad_default_ttl_hours()));
661            if msg_privacy == PrivacyLevel::Private && to_agent.is_none() {
662                return "Error: private messages require to_agent".to_string();
663            }
664            let registry = AgentRegistry::load_or_create();
665            // `queen` reaches the registered Queen (QN0), never an agent that
666            // only took the role name.
667            let to_agent = match to_agent
668                .map(|t| lean_ctx_hive::bus::queen_kinds::resolve_to(&registry, t))
669                .transpose()
670            {
671                Ok(to) => to,
672                Err(refused) => return format!("Error: not posted: {refused}"),
673            };
674            let to_agent = to_agent.as_deref();
675            let (task_id, metadata) =
676                match checked_post_fields(&registry, from, post, &msg_priority) {
677                    Ok(fields) => fields,
678                    Err(refused) => return format!("Error: not posted: {refused}"),
679                };
680            // No approval in a person's name (refused), and only the
681            // registered Queen appears as the Queen (process v1 §7): another
682            // sender is refused (`enforce`) or marked for its receivers (`warn`).
683            let msg = {
684                // The sender among the active presences; a question, offer or
685                // handoff that passes on a refused action is refused too.
686                match lean_ctx_hive::bus::person_rules::check_local_post(
687                    &registry,
688                    current_agent_id,
689                    cat,
690                    to_agent,
691                    msg,
692                ) {
693                    Ok(text) => text,
694                    Err(refused) => return format!("Error: not posted: {refused}"),
695                }
696            };
697            let msg = msg.as_str();
698            let request = AgentMessageRequest {
699                project_root: project_root.to_string(),
700                from_agent_id: from.to_string(),
701                to_agent_id: to_agent.map(str::to_string),
702                category: cat.to_string(),
703                message: msg.to_string(),
704                privacy: msg_privacy,
705                priority: msg_priority,
706                ttl_hours,
707                request_id: request_id.map(str::to_string),
708                task_id,
709                metadata,
710            };
711            match OclaRegistry::global().agent_gateway.route_message(&request) {
712                Ok(msg_id) => {
713                    let target = to_agent.unwrap_or("all agents (broadcast)");
714                    format!("Posted [{cat}] to {target}: {msg} (id: {msg_id})")
715                }
716                Err(e) => format!("Post failed: {e}"),
717            }
718        }
719
720        // The text of either outcome, for callers of this untyped entry
721        // point; the registered tool takes the typed one (`handle_read`).
722        "read" => match read_messages(current_agent_id, project_root) {
723            Ok(text) | Err(text) => text,
724        },
725
726        // This entry point has no `timeout_secs`: the default applies; the
727        // registered tool passes it (`handle_wait`).
728        "wait" => match handle_wait(current_agent_id, project_root, None) {
729            Ok(text) | Err(text) => text,
730        },
731
732        // Hive agent messages (I1). This entry point has no `type`/`reply_to`
733        // arguments: `category` carries the type; the registered tool passes all.
734        "send" => handle_message(
735            "send",
736            &MessageArgs::untyped(message, category, to_agent),
737            project_root,
738            current_agent_id,
739        ),
740        "reply" => handle_message(
741            "reply",
742            &MessageArgs::untyped(message, category, to_agent),
743            project_root,
744            current_agent_id,
745        ),
746        "inbox" => handle_message(
747            "inbox",
748            &MessageArgs::untyped(message, category, to_agent),
749            project_root,
750            current_agent_id,
751        ),
752
753        "status" => {
754            let Some(agent_id) = current_agent_id else {
755                return "Error: agent must be registered first".to_string();
756            };
757            let new_status = match status {
758                Some("active") => AgentStatus::Active,
759                Some("idle") => AgentStatus::Idle,
760                Some("finished") => AgentStatus::Finished,
761                Some(other) => {
762                    return format!("Unknown status: {other}. Use: active, idle, finished");
763                }
764                None => return "Error: status value is required".to_string(),
765            };
766            let status_msg = message;
767
768            match AgentRegistry::mutate_locked(|registry| {
769                registry.set_status(agent_id, new_status.clone(), status_msg)
770            })
771            .and_then(|(_, result)| result)
772            {
773                Ok(()) => format!(
774                    "Status updated: {} → {}{}",
775                    agent_id,
776                    new_status,
777                    status_msg.map(|m| format!(" ({m})")).unwrap_or_default()
778                ),
779                Err(e) => format!("Status update rejected: {e}"),
780            }
781        }
782
783        "info" => {
784            let registry = AgentRegistry::mutate_locked(|registry| {
785                registry.cleanup_stale(presence_ttl_hours());
786            })
787            .map(|(registry, ())| registry)
788            .unwrap_or_else(|_| AgentRegistry::load_or_create());
789            let total = registry.agents.len();
790            let active = registry.list_active(None).len();
791            let messages = registry.scratchpad.len();
792            format!(
793                "Agent Registry: {total} total, {active} active, {messages} scratchpad entries\nLast updated: {}",
794                registry.updated_at.format("%Y-%m-%d %H:%M UTC")
795            )
796        }
797
798        "handoff" => {
799            let Some(from) = current_agent_id else {
800                return "Error: agent must be registered first".to_string();
801            };
802            let Some(target) = to_agent else {
803                return "Error: to_agent is required for handoff".to_string();
804            };
805            let summary = message.unwrap_or("(no summary provided)");
806            let scratchpad_ttl = scratchpad_default_ttl_hours();
807            // A worker of another machine (`wkr_…`, as `ctx_agent list`
808            // shows it): the handoff is a typed message through Hive, which
809            // that worker answers (accept or decline). Any other target is
810            // an agent on this machine.
811            if target.starts_with("wkr_") {
812                use lean_ctx_hive::bus::messages_v2::{Extras, send};
813                let extras = Extras {
814                    to_worker: Some(target),
815                    ..Extras::default()
816                };
817                return match send(project_root, from, Some("handoff"), summary, extras) {
818                    Ok(id) => {
819                        let waiting = format!("handoff to {target} offered through Hive");
820                        let _ = AgentRegistry::mutate_locked(|registry| {
821                            registry.set_status(from, AgentStatus::Idle, Some(&waiting))
822                        });
823                        format!(
824                            "Handoff offered (hive): {from} → {target} message={id}; \
825                             {target} answers it (accept or decline)\nSummary: {summary}"
826                        )
827                    }
828                    Err(error) => format!("Handoff rejected: {error}"),
829                };
830            }
831
832            if let Err(error) = AgentRegistry::mutate_locked(|registry| {
833                // The handoff message must be persisted before the sender is
834                // marked Finished. A bound presence that finishes first can no
835                // longer post, so the delivery would be silently dropped.
836                registry.post_message_scoped(
837                    Some(project_root),
838                    from,
839                    Some(target),
840                    "handoff",
841                    &format!("HANDOFF from {from}: {summary}"),
842                    PrivacyLevel::Team,
843                    MessagePriority::Normal,
844                    Some(scratchpad_ttl),
845                )?;
846                registry.set_status(from, AgentStatus::Finished, Some("handed off"))
847            })
848            .and_then(|(_, result)| result)
849            {
850                return format!("Handoff rejected: {error}");
851            }
852
853            // Route through OCLA AgentGateway for cross-process visibility.
854            let handoff_message = format!("HANDOFF: {summary}");
855            let capsule_ref = global_capsule_store().register(handoff_message.as_bytes());
856            let mut envelope = AgentEnvelope {
857                schema_version: AGENT_ENVELOPE_SCHEMA_VERSION,
858                relay_id: String::new(),
859                context: OclaRequestContext::new(
860                    format!("handoff:{capsule_ref}"),
861                    project_root.to_string(),
862                    from.to_string(),
863                    capsule_ref.clone(),
864                    None,
865                    Some(format!("handoff:{from}:{target}")),
866                ),
867                from_agent_id: from.to_string(),
868                to_agent_id: target.to_string(),
869                capsule_ref,
870                budget_tokens: handoff_message.len().max(1) as u64,
871            };
872            if let Err(error) = envelope.assign_relay_id() {
873                tracing::debug!("OCLA relay setup failed (non-fatal): {error}");
874            } else if let Err(error) = OclaRegistry::global().agent_gateway.relay_agent(envelope) {
875                tracing::debug!("OCLA relay failed (non-fatal): {error}");
876            }
877
878            // Stigmergy (#540): mark the handed-off work as Done in the field
879            // so other agents see it arithmetically, without reading messages.
880            lean_ctx::core::scent_field::deposit_for(
881                project_root,
882                from,
883                lean_ctx::core::scent_field::ScentKind::Done,
884                summary,
885                1.0,
886            );
887
888            format!("Handoff complete: {from} → {target}\nSummary: {summary}")
889        }
890
891        // Stigmergic claim (#540): atomically claim a target (file, task,
892        // deploy unit) in the shared scent field. Fails fast when another
893        // agent's claim is still active — prevents duplicate work for ~0 tokens.
894        "claim" => {
895            let Some(target) = message else {
896                return "Error: message (the claim target, e.g. a file path or task label) is required for claim".to_string();
897            };
898            let agent = current_agent_id.map_or_else(
899                || lean_ctx::core::scent_field::scent_agent_id().to_string(),
900                str::to_string,
901            );
902            let normalized = lean_ctx::core::pathutil::normalize_tool_path(target);
903            match lean_ctx::core::scent_field::claim(project_root, &agent, &normalized) {
904                Ok(()) => {
905                    format!("Claimed: {normalized} (by {agent}, decays in ~10m unless re-claimed)")
906                }
907                Err(e) => format!("Claim REJECTED: {normalized} — {e}"),
908            }
909        }
910
911        // Release a stigmergic claim early (done or abandoned).
912        "release" => {
913            let Some(target) = message else {
914                return "Error: message (the claim target) is required for release".to_string();
915            };
916            let agent = current_agent_id.map_or_else(
917                || lean_ctx::core::scent_field::scent_agent_id().to_string(),
918                str::to_string,
919            );
920            let normalized = lean_ctx::core::pathutil::normalize_tool_path(target);
921            lean_ctx::core::scent_field::release(project_root, &agent, &normalized);
922            format!("Released: {normalized}")
923        }
924
925        // Sub-agent context contract (GL#450): deterministic briefing pack.
926        // `message` is the task; `priority` doubles as the token budget when
927        // numeric (default 2000). Same knowledge + task ⇒ byte-identical pack.
928        "brief" => {
929            let Some(task) = message else {
930                return "Error: message (the sub-agent task) is required for brief".to_string();
931            };
932            let budget = priority
933                .and_then(|p| p.parse::<usize>().ok())
934                .unwrap_or(2000);
935            let Some(knowledge) = lean_ctx::core::knowledge::ProjectKnowledge::load(project_root)
936            else {
937                return "No knowledge stored for this project yet — a briefing pack needs facts. Use ctx_knowledge(action=\"remember\") first.".to_string();
938            };
939            // The pack builder leaves out facts that may not be delivered now.
940            let pack =
941                lean_ctx::core::subagent_contract::build_briefing_pack(&knowledge, task, budget);
942            match lean_ctx::core::subagent_contract::serialize_pack(&pack) {
943                Ok(json) => json,
944                Err(e) => format!("Error: {e}"),
945            }
946        }
947
948        // Return synthesis (GL#450): distill a sub-agent's report into
949        // recallable parent knowledge. Expects contract-formatted lines
950        // ('category/key: value'); rejects are listed, never silently dropped.
951        "return" => {
952            let Some(report) = message else {
953                return "Error: message (the sub-agent report) is required for return".to_string();
954            };
955            let (facts, rejected) = lean_ctx::core::subagent_contract::parse_return_lines(report);
956            if facts.is_empty() {
957                return format!(
958                    "No contract-formatted lines found ({} rejected). Expected 'category/key: value' per line.",
959                    rejected.len()
960                );
961            }
962
963            let session_label = current_agent_id.unwrap_or("subagent");
964            let policy = match crate::tools::knowledge_shared::load_policy_or_error() {
965                Ok(p) => p,
966                Err(e) => return e,
967            };
968            let count = facts.len();
969            let res = lean_ctx::core::knowledge::ProjectKnowledge::mutate_locked(
970                project_root,
971                |knowledge| {
972                    for f in &facts {
973                        knowledge.remember(
974                            &f.category,
975                            &f.key,
976                            &f.value,
977                            session_label,
978                            0.8,
979                            &policy,
980                        );
981                    }
982                },
983            );
984            match res {
985                Ok(_) => {
986                    let mut out = format!(
987                        "Return synthesis: {count} fact(s) distilled into parent knowledge"
988                    );
989                    if !rejected.is_empty() {
990                        out.push_str(&format!(
991                            "\n{} line(s) rejected (not 'category/key: value'):",
992                            rejected.len()
993                        ));
994                        for r in rejected.iter().take(5) {
995                            out.push_str(&format!("\n  ✗ {r}"));
996                        }
997                    }
998                    out
999                }
1000                Err(e) => format!("Error: knowledge store update failed: {e}"),
1001            }
1002        }
1003
1004        "sync" => {
1005            let registry = AgentRegistry::mutate_locked(|registry| {
1006                registry.cleanup_stale(presence_ttl_hours());
1007            })
1008            .map(|(registry, ())| registry)
1009            .unwrap_or_else(|_| AgentRegistry::load_or_create());
1010            let pending_count = current_agent_id.map_or(0, |id| {
1011                registry
1012                    .scratchpad
1013                    .iter()
1014                    .filter(|e| {
1015                        !e.read_by.contains(&id.to_string())
1016                            && e.from_agent != id
1017                            && (e.to_agent.is_none() || e.to_agent.as_deref() == Some(id))
1018                            && e.project_root.as_deref().is_some_and(|own| {
1019                                lean_ctx::core::pathutil::same_project(own, project_root)
1020                            })
1021                    })
1022                    .count()
1023            });
1024            let agents = registry.list_active(Some(project_root));
1025
1026            if agents.is_empty() {
1027                return "No active agents to sync with.".to_string();
1028            }
1029
1030            // Only this project's shares (`ctx_share` partitions by project).
1031            let shared_dir = lean_ctx::core::data_dir::lean_ctx_data_dir()
1032                .unwrap_or_default()
1033                .join("agents")
1034                .join("shared")
1035                .join(lean_ctx::core::project_hash::hash_project_root(
1036                    project_root,
1037                ));
1038
1039            let shared_count = if shared_dir.exists() {
1040                std::fs::read_dir(&shared_dir).map_or(0, std::iter::Iterator::count)
1041            } else {
1042                0
1043            };
1044
1045            let mut out = "Multi-Agent Sync Status:\n".to_string();
1046            out.push_str(&format!("  Active agents: {}\n", agents.len()));
1047            for a in &agents {
1048                let role = a.role.as_deref().unwrap_or("-");
1049                let age = (chrono::Utc::now() - a.last_active).num_minutes();
1050                out.push_str(&format!(
1051                    "    {} [{}] role={} ({}m ago)\n",
1052                    a.agent_id, a.agent_type, role, age
1053                ));
1054            }
1055            out.push_str(&format!("  Pending messages: {pending_count}\n"));
1056            out.push_str(&format!("  Shared contexts: {shared_count}\n"));
1057
1058            // Stigmergy (#540): arithmetic field view — claims, stuck markers,
1059            // hot files across this project's agents, no scratchpad reads needed.
1060            let scents = lean_ctx::core::scent_field::sync_block(project_root);
1061            if !scents.is_empty() {
1062                out.push_str(&scents);
1063            }
1064            out
1065        }
1066
1067        // Premium control-plane snapshot: one bounded, read-only view of
1068        // presence, messages and persisted Work Graph execution state.
1069        // This keeps agents from guessing whether they should retry, wait or
1070        // integrate, and gives the operator a stable next action.
1071        "control_status" => control_status(project_root, current_agent_id),
1072
1073        "export" => {
1074            let Some(agent_id) = current_agent_id else {
1075                return "Error: agent must be registered first (use action=register)".to_string();
1076            };
1077
1078            fn privacy_label(p: &PrivacyLevel) -> &'static str {
1079                match p {
1080                    PrivacyLevel::Public => "public",
1081                    PrivacyLevel::Team => "team",
1082                    PrivacyLevel::Private => "private",
1083                }
1084            }
1085
1086            fn priority_label(p: &MessagePriority) -> &'static str {
1087                match p {
1088                    MessagePriority::Low => "low",
1089                    MessagePriority::Normal => "normal",
1090                    MessagePriority::High => "high",
1091                    MessagePriority::Critical => "critical",
1092                }
1093            }
1094
1095            fn maybe_redact(s: &str, should_redact: bool) -> String {
1096                if should_redact {
1097                    lean_ctx::core::redaction::redact_text(s)
1098                } else {
1099                    s.to_string()
1100                }
1101            }
1102
1103            #[derive(serde::Serialize)]
1104            struct ExportAgentV1 {
1105                agent_id: String,
1106                agent_type: String,
1107                role: Option<String>,
1108                status: String,
1109                status_message: Option<String>,
1110                started_at: String,
1111                last_active: String,
1112                pid: u32,
1113            }
1114
1115            #[derive(serde::Serialize)]
1116            struct ExportMessageV1 {
1117                id: String,
1118                from_agent: String,
1119                to_agent: Option<String>,
1120                category: String,
1121                privacy: String,
1122                priority: String,
1123                message: String,
1124                metadata: std::collections::BTreeMap<String, String>,
1125                timestamp: String,
1126                expires_at: Option<String>,
1127                read_by_count: usize,
1128            }
1129
1130            #[derive(serde::Serialize)]
1131            struct ExportTaskV1 {
1132                id: String,
1133                from_agent: String,
1134                to_agent: String,
1135                state: String,
1136                description: String,
1137                created_at: String,
1138                updated_at: String,
1139                messages: usize,
1140                artifacts: usize,
1141                transitions: usize,
1142            }
1143
1144            #[derive(serde::Serialize)]
1145            struct ExportDiaryEntryV1 {
1146                entry_type: String,
1147                content: String,
1148                context: Option<String>,
1149                timestamp: String,
1150            }
1151
1152            #[derive(serde::Serialize)]
1153            struct ExportDiaryV1 {
1154                agent_id: String,
1155                agent_type: String,
1156                project_root: String,
1157                updated_at: String,
1158                entries: Vec<ExportDiaryEntryV1>,
1159            }
1160
1161            #[derive(serde::Serialize)]
1162            struct A2ASnapshotV1 {
1163                schema_version: u32,
1164                created_at: String,
1165                project_root: String,
1166                agent_id: String,
1167                agents: Vec<ExportAgentV1>,
1168                messages: Vec<ExportMessageV1>,
1169                tasks: Vec<ExportTaskV1>,
1170                diary: Option<ExportDiaryV1>,
1171            }
1172
1173            let privacy_mode = privacy.unwrap_or("redacted");
1174            let allow_full = privacy_mode == "full"
1175                && !lean_ctx::core::redaction::redaction_enabled_for_active_role();
1176            let should_redact = !allow_full;
1177
1178            let now = chrono::Utc::now();
1179            let mut registry = AgentRegistry::load_or_create();
1180            registry.cleanup_stale(presence_ttl_hours());
1181
1182            let mut agents: Vec<ExportAgentV1> = registry
1183                .list_active(Some(project_root))
1184                .into_iter()
1185                .map(|a| ExportAgentV1 {
1186                    agent_id: a.agent_id.clone(),
1187                    agent_type: a.agent_type.clone(),
1188                    role: a.role.clone(),
1189                    status: a.status.to_string(),
1190                    status_message: a.status_message.clone(),
1191                    started_at: a.started_at.to_rfc3339(),
1192                    last_active: a.last_active.to_rfc3339(),
1193                    pid: a.pid,
1194                })
1195                .collect();
1196            agents.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
1197
1198            let mut messages: Vec<ExportMessageV1> = registry
1199                .scratchpad
1200                .iter()
1201                .filter(|e| {
1202                    e.project_root.as_deref().is_some_and(|own| {
1203                        lean_ctx::core::pathutil::same_project(own, project_root)
1204                    }) && (e.to_agent.is_none() || e.to_agent.as_deref() == Some(agent_id))
1205                })
1206                .take(200)
1207                .map(|m| ExportMessageV1 {
1208                    id: m.id.clone(),
1209                    from_agent: m.from_agent.clone(),
1210                    to_agent: m.to_agent.clone(),
1211                    category: m.category.clone(),
1212                    privacy: privacy_label(&m.privacy).to_string(),
1213                    priority: priority_label(&m.priority).to_string(),
1214                    message: maybe_redact(&m.served_message(Some(project_root)), should_redact),
1215                    // The admission binding is store-internal provenance.
1216                    metadata: m
1217                        .metadata
1218                        .iter()
1219                        .filter(|(k, _)| k.as_str() != lean_ctx::core::agents::BINDING_METADATA_KEY)
1220                        .map(|(k, v)| (k.clone(), maybe_redact(v, should_redact)))
1221                        .collect(),
1222                    timestamp: m.timestamp.to_rfc3339(),
1223                    expires_at: m.expires_at.map(|t| t.to_rfc3339()),
1224                    read_by_count: m.read_by.len(),
1225                })
1226                .collect();
1227            messages.sort_by(|a, b| a.timestamp.cmp(&b.timestamp).then_with(|| a.id.cmp(&b.id)));
1228
1229            // Prune and read under the store lock: a concurrent task mutation
1230            // is either fully before or fully after this one, never lost.
1231            // Export runs once per request, so it waits for a busy store
1232            // instead of failing after the default lock timeout.
1233            const EXPORT_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
1234            let tasks = TaskStore::default_path().and_then(|path| {
1235                TaskStore::mutate_locked_within(&path, EXPORT_LOCK_WAIT, |task_store| {
1236                    task_store.cleanup_old(72);
1237                    Ok::<_, std::io::Error>(
1238                        task_store
1239                            .tasks_for_agent(agent_id)
1240                            .into_iter()
1241                            .take(200)
1242                            .map(|t| ExportTaskV1 {
1243                                id: t.id.clone(),
1244                                from_agent: t.from_agent.clone(),
1245                                to_agent: t.to_agent.clone(),
1246                                state: t.state.to_string(),
1247                                description: maybe_redact(&t.served_description(), should_redact),
1248                                created_at: t.created_at.to_rfc3339(),
1249                                updated_at: t.updated_at.to_rfc3339(),
1250                                messages: t.messages.len(),
1251                                artifacts: t.artifacts.len(),
1252                                transitions: t.history.len(),
1253                            })
1254                            .collect::<Vec<ExportTaskV1>>(),
1255                    )
1256                })
1257            });
1258            let mut tasks = match tasks {
1259                Ok(tasks) => tasks,
1260                Err(error) => {
1261                    tracing::warn!("lean-ctx: failed to read task store: {error}");
1262                    return "Error: task storage unavailable".to_string();
1263                }
1264            };
1265            tasks.sort_by(|a, b| {
1266                b.updated_at
1267                    .cmp(&a.updated_at)
1268                    .then_with(|| a.id.cmp(&b.id))
1269            });
1270
1271            let diary =
1272                AgentDiary::load_for_project(agent_id, project_root).map(|d| ExportDiaryV1 {
1273                    agent_id: d.agent_id,
1274                    agent_type: d.agent_type,
1275                    project_root: d.project_root,
1276                    updated_at: d.updated_at.to_rfc3339(),
1277                    entries: d
1278                        .entries
1279                        .iter()
1280                        .rev()
1281                        .take(25)
1282                        .rev()
1283                        .map(|e| ExportDiaryEntryV1 {
1284                            entry_type: e.entry_type.to_string(),
1285                            content: maybe_redact(&e.content, should_redact),
1286                            context: e.context.as_deref().map(|c| maybe_redact(c, should_redact)),
1287                            timestamp: e.timestamp.to_rfc3339(),
1288                        })
1289                        .collect(),
1290                });
1291
1292            let payload = A2ASnapshotV1 {
1293                schema_version: lean_ctx::core::contracts::A2A_SNAPSHOT_V1_SCHEMA_VERSION,
1294                created_at: now.to_rfc3339(),
1295                project_root: project_root.to_string(),
1296                agent_id: agent_id.to_string(),
1297                agents,
1298                messages,
1299                tasks,
1300                diary,
1301            };
1302
1303            let json = serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string());
1304
1305            if write {
1306                let name = filename.map_or_else(
1307                    || format!("a2a-snapshot-v1_{}.json", now.format("%Y%m%d_%H%M%S")),
1308                    str::to_string,
1309                );
1310                let out_path =
1311                    match write_a2a_snapshot(std::path::Path::new(project_root), &name, &json) {
1312                        Ok(path) => path,
1313                        Err(e) => return format!("Error: write snapshot: {e}"),
1314                    };
1315
1316                return format!(
1317                    "A2A snapshot exported: {}\n  agents: {}\n  messages: {}\n  tasks: {}",
1318                    out_path.display(),
1319                    payload.agents.len(),
1320                    payload.messages.len(),
1321                    payload.tasks.len()
1322                );
1323            }
1324
1325            match format.unwrap_or("json") {
1326                "text" => format!(
1327                    "A2A snapshot (v1)\n  agents: {}\n  messages: {}\n  tasks: {}",
1328                    payload.agents.len(),
1329                    payload.messages.len(),
1330                    payload.tasks.len()
1331                ),
1332                _ => json,
1333            }
1334        }
1335
1336        "diary" => {
1337            let Some(agent_id) = current_agent_id else {
1338                return "Error: agent must be registered first".to_string();
1339            };
1340            let Some(content) = message else {
1341                return "Error: message is required for diary entry".to_string();
1342            };
1343            let entry_type = match category.unwrap_or("progress") {
1344                "discovery" | "found" => DiaryEntryType::Discovery,
1345                "decision" | "decided" => DiaryEntryType::Decision,
1346                "blocker" | "blocked" => DiaryEntryType::Blocker,
1347                "progress" | "done" => DiaryEntryType::Progress,
1348                "insight" => DiaryEntryType::Insight,
1349                other => {
1350                    return format!(
1351                        "Unknown diary type: {other}. Use: discovery, decision, blocker, progress, insight"
1352                    );
1353                }
1354            };
1355            let atype = agent_type.unwrap_or("unknown");
1356            let mut diary = AgentDiary::load_or_create(agent_id, atype, project_root);
1357            let context_str = to_agent;
1358            diary.add_entry(entry_type.clone(), content, context_str);
1359            match diary.save() {
1360                Ok(()) => format!("Diary entry [{entry_type}] added: {content}"),
1361                Err(e) => format!("Diary entry added but save failed: {e}"),
1362            }
1363        }
1364
1365        "recall_diary" | "diary_recall" => {
1366            let Some(agent_id) = current_agent_id else {
1367                let diaries = AgentDiary::list_for_project(project_root);
1368                if diaries.is_empty() {
1369                    return "No agent diaries found.".to_string();
1370                }
1371                let mut out = format!("Agent Diaries ({}):\n", diaries.len());
1372                for (id, count, updated) in &diaries {
1373                    let age = (chrono::Utc::now() - *updated).num_minutes();
1374                    out.push_str(&format!("  {id}: {count} entries ({age}m ago)\n"));
1375                }
1376                return out;
1377            };
1378            match AgentDiary::load_for_project(agent_id, project_root) {
1379                Some(diary) => diary.format_summary(),
1380                None => format!("No diary found for agent '{agent_id}'."),
1381            }
1382        }
1383
1384        "diaries" => {
1385            let diaries = AgentDiary::list_for_project(project_root);
1386            if diaries.is_empty() {
1387                return "No agent diaries found.".to_string();
1388            }
1389            let mut out = format!("Agent Diaries ({}):\n", diaries.len());
1390            for (id, count, updated) in &diaries {
1391                let age = (chrono::Utc::now() - *updated).num_minutes();
1392                out.push_str(&format!("  {id}: {count} entries ({age}m ago)\n"));
1393            }
1394            out
1395        }
1396
1397        "share_knowledge" => {
1398            let cat = category.unwrap_or("general");
1399            let Some(msg_text) = message else {
1400                return "Error: message required (format: key1=value1;key2=value2)".to_string();
1401            };
1402            let facts: Vec<(String, String)> = msg_text
1403                .split(';')
1404                .filter_map(|kv| {
1405                    let (k, v) = kv.split_once('=')?;
1406                    Some((k.trim().to_string(), v.trim().to_string()))
1407                })
1408                .collect();
1409            if facts.is_empty() {
1410                return "Error: no valid key=value pairs found".to_string();
1411            }
1412            let Some(from) = current_agent_id else {
1413                return "Error: agent must be registered first".to_string();
1414            };
1415            // Shared knowledge reaches only the agents of this project.
1416            match AgentRegistry::mutate_locked(|registry| {
1417                registry.require_active_for_presence(from, "sharing knowledge")?;
1418                registry.share_knowledge(project_root, from, cat, &facts)
1419            })
1420            .and_then(|(_, shared)| shared)
1421            {
1422                Ok(count) => format!("Shared {count} facts in category '{cat}' with this project"),
1423                Err(e) => format!("Share failed: {e}"),
1424            }
1425        }
1426
1427        "receive_knowledge" => {
1428            let Some(agent_id) = current_agent_id else {
1429                return "Error: agent must be registered first".to_string();
1430            };
1431            let facts = match AgentRegistry::mutate_locked(|registry| {
1432                registry.require_active_for_presence(agent_id, "receiving knowledge")?;
1433                registry.receive_shared_knowledge(project_root, agent_id)
1434            })
1435            .and_then(|(_, facts)| facts)
1436            {
1437                Ok(facts) => facts,
1438                Err(e) => return format!("Receive failed: {e}"),
1439            };
1440            if facts.is_empty() {
1441                return "No new shared knowledge.".to_string();
1442            }
1443            let mut out = format!("Received {} facts:\n", facts.len());
1444            for f in &facts {
1445                let age = (chrono::Utc::now() - f.timestamp).num_minutes();
1446                out.push_str(&format!(
1447                    "  [{}] {} (from {}, {}m ago)\n",
1448                    f.category, f.text, f.from_agent, age
1449                ));
1450            }
1451            out
1452        }
1453
1454        "poll_events" => {
1455            let Some(agent_id) = current_agent_id else {
1456                return "Error: agent must be registered first".to_string();
1457            };
1458            let workspace_id = to_agent.unwrap_or(project_root);
1459            let channel_id = category.unwrap_or("default");
1460            let since_id: i64 = message.and_then(|s| s.parse().ok()).unwrap_or(0);
1461            let limit: usize = _ttl_hours.unwrap_or(50) as usize;
1462
1463            let rt = lean_ctx::core::context_os::runtime();
1464            let events = rt.bus.read(workspace_id, channel_id, since_id, limit);
1465
1466            let filter = lean_ctx::core::context_os::TopicFilter {
1467                agent_id: Some(agent_id.to_string()),
1468                kinds: privacy.and_then(|s| {
1469                    let kinds: Vec<_> = s
1470                        .split(',')
1471                        .map(|k| lean_ctx::core::context_os::ContextEventKindV1::parse(k.trim()))
1472                        .collect();
1473                    if kinds.is_empty() { None } else { Some(kinds) }
1474                }),
1475                ..Default::default()
1476            };
1477
1478            let filtered: Vec<_> = events.into_iter().filter(|e| filter.matches(e)).collect();
1479            if filtered.is_empty() {
1480                return format!("No new events since id={since_id} for {agent_id}.");
1481            }
1482
1483            let mut out = format!("Events ({}, since={since_id}):\n", filtered.len());
1484            for ev in &filtered {
1485                let actor = ev.actor.as_deref().unwrap_or("-");
1486                out.push_str(&format!(
1487                    "  #{} [{}] actor={} cl={} ({})\n",
1488                    ev.id,
1489                    ev.kind,
1490                    actor,
1491                    ev.consistency_level,
1492                    ev.timestamp.format("%H:%M:%S")
1493                ));
1494            }
1495            if let Some(last) = filtered.last() {
1496                out.push_str(&format!("cursor={}", last.id));
1497            }
1498            out
1499        }
1500
1501        // P11: path/symbol leases for mutation planning (see `lease`).
1502        "lease_acquire" => lease::acquire(project_root, current_agent_id, message, _ttl_hours),
1503        "lease_release" => lease::release(project_root, current_agent_id, message, category),
1504        "lease_renew" => lease::renew(
1505            project_root,
1506            current_agent_id,
1507            message,
1508            category,
1509            _ttl_hours,
1510        ),
1511
1512        _ => format!("Unknown action: {action}. Use: {}", ACTIONS.join(", ")),
1513    }
1514}
1515
1516/// Write an exported A2A snapshot to `<project>/.lean-ctx/proofs/<name>` through
1517/// the shared proof destination: a plain file name only, and no symlinked
1518/// `.lean-ctx`, `proofs` or target file.
1519fn write_a2a_snapshot(
1520    project_root: &std::path::Path,
1521    name: &str,
1522    json: &str,
1523) -> Result<std::path::PathBuf, String> {
1524    lean_ctx::core::context_proof::proof_destination(project_root, name)?.write(json)
1525}
1526
1527#[cfg(test)]
1528#[path = "ctx_agent_tests.rs"]
1529mod tests;
1530// SPDX-License-Identifier: Apache-2.0