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