1use crate::core::a2a::message::{MessagePriority, PrivacyLevel};
2use crate::core::a2a::task::TaskStore;
3use crate::core::agents::{AgentDiary, AgentRegistry, AgentStatus, DiaryEntryType};
4use crate::core::evidence_ledger::EvidenceLedgerV1;
5use crate::core::ocla::registry::OclaRegistry;
6
7#[allow(clippy::too_many_arguments)]
8pub fn handle(
9 action: &str,
10 agent_type: Option<&str>,
11 role: Option<&str>,
12 project_root: &str,
13 current_agent_id: Option<&str>,
14 message: Option<&str>,
15 category: Option<&str>,
16 to_agent: Option<&str>,
17 status: Option<&str>,
18 privacy: Option<&str>,
19 priority: Option<&str>,
20 _ttl_hours: Option<u64>,
21 format: Option<&str>,
22 write: bool,
23 filename: Option<&str>,
24) -> String {
25 match action {
26 "register" => {
27 let atype = agent_type.unwrap_or("unknown");
28 match AgentRegistry::mutate_locked(|registry| {
29 registry.cleanup_stale(24);
30 registry.register(atype, role, project_root)
31 }) {
32 Ok((_, agent_id)) => format!(
33 "Agent registered: {agent_id} (type: {atype}, role: {})",
34 role.unwrap_or("none")
35 ),
36 Err(e) => format!("Registration failed: {e}"),
37 }
38 }
39
40 "list" => {
41 let registry = match AgentRegistry::mutate_locked(|registry| {
42 registry.cleanup_stale(24);
43 }) {
44 Ok((registry, ())) => registry,
45 Err(e) => {
46 tracing::warn!("lean-ctx: failed to persist agent registry: {e}");
47 AgentRegistry::load_or_create()
48 }
49 };
50
51 let agents = registry.list_active(Some(project_root));
52 if agents.is_empty() {
53 return "No active agents for this project.".to_string();
54 }
55
56 let mut out = format!("Active agents ({}):\n", agents.len());
57 for a in agents {
58 let role_str = a.role.as_deref().unwrap_or("-");
59 let status_msg = a
60 .status_message
61 .as_deref()
62 .map(|m| format!(" — {m}"))
63 .unwrap_or_default();
64 let age = (chrono::Utc::now() - a.last_active).num_minutes();
65 out.push_str(&format!(
66 " {} [{}] role={} status={}{} (last active: {}m ago, pid: {})\n",
67 a.agent_id, a.agent_type, role_str, a.status, status_msg, age, a.pid
68 ));
69 }
70 out
71 }
72
73 "post" => {
74 let Some(msg) = message else {
75 return "Error: message is required for post".to_string();
76 };
77 let cat = category.unwrap_or("status");
78 let from = current_agent_id.unwrap_or("anonymous");
79 let msg_privacy = privacy.map_or(PrivacyLevel::Team, PrivacyLevel::parse_str);
80 let msg_priority = priority.map_or(MessagePriority::Normal, MessagePriority::parse_str);
81 if msg_privacy == PrivacyLevel::Private && to_agent.is_none() {
82 return "Error: private messages require to_agent".to_string();
83 }
84 match OclaRegistry::global().agent_gateway.route_message(
85 from,
86 to_agent,
87 cat,
88 msg,
89 msg_privacy,
90 msg_priority,
91 _ttl_hours,
92 ) {
93 Ok(msg_id) => {
94 let target = to_agent.unwrap_or("all agents (broadcast)");
95 format!("Posted [{cat}] to {target}: {msg} (id: {msg_id})")
96 }
97 Err(e) => format!("Post failed: {e}"),
98 }
99 }
100
101 "read" => {
102 let Some(agent_id) = current_agent_id else {
103 return "Error: agent must be registered first (use action=register)".to_string();
104 };
105 let messages = match AgentRegistry::mutate_locked(|registry| {
106 registry
107 .read_unread(agent_id)
108 .into_iter()
109 .cloned()
110 .collect::<Vec<_>>()
111 }) {
112 Ok((_, messages)) => messages,
113 Err(e) => {
114 tracing::warn!(
115 "lean-ctx: failed to persist agent registry (messages may reappear): {e}"
116 );
117 return "Error: failed to read messages".to_string();
118 }
119 };
120
121 if messages.is_empty() {
122 return "No new messages.".to_string();
123 }
124
125 let mut out = format!("New messages ({}):\n", messages.len());
126 for m in &messages {
127 let age = (chrono::Utc::now() - m.timestamp).num_minutes();
128 out.push_str(&format!(
129 " [{}] from {} ({}m ago): {}\n",
130 m.category, m.from_agent, age, m.message
131 ));
132 }
133 out
134 }
135
136 "status" => {
137 let Some(agent_id) = current_agent_id else {
138 return "Error: agent must be registered first".to_string();
139 };
140 let new_status = match status {
141 Some("active") => AgentStatus::Active,
142 Some("idle") => AgentStatus::Idle,
143 Some("finished") => AgentStatus::Finished,
144 Some(other) => {
145 return format!("Unknown status: {other}. Use: active, idle, finished");
146 }
147 None => return "Error: status value is required".to_string(),
148 };
149 let status_msg = message;
150
151 match AgentRegistry::mutate_locked(|registry| {
152 registry.set_status(agent_id, new_status.clone(), status_msg);
153 }) {
154 Ok(_) => format!(
155 "Status updated: {} → {}{}",
156 agent_id,
157 new_status,
158 status_msg.map(|m| format!(" ({m})")).unwrap_or_default()
159 ),
160 Err(e) => format!("Status set but save failed: {e}"),
161 }
162 }
163
164 "info" => {
165 let registry = AgentRegistry::load_or_create();
166 let total = registry.agents.len();
167 let active = registry
168 .agents
169 .iter()
170 .filter(|a| a.status == AgentStatus::Active)
171 .count();
172 let messages = registry.scratchpad.len();
173 format!(
174 "Agent Registry: {total} total, {active} active, {messages} scratchpad entries\nLast updated: {}",
175 registry.updated_at.format("%Y-%m-%d %H:%M UTC")
176 )
177 }
178
179 "handoff" => {
180 let Some(from) = current_agent_id else {
181 return "Error: agent must be registered first".to_string();
182 };
183 let Some(target) = to_agent else {
184 return "Error: to_agent is required for handoff".to_string();
185 };
186 let summary = message.unwrap_or("(no summary provided)");
187
188 let _ = AgentRegistry::mutate_locked(|registry| {
189 registry.post_message(
190 from,
191 Some(target),
192 "handoff",
193 &format!("HANDOFF from {from}: {summary}"),
194 );
195 registry.set_status(from, AgentStatus::Finished, Some("handed off"));
196 });
197
198 crate::core::scent_field::deposit(
201 from,
202 crate::core::scent_field::ScentKind::Done,
203 summary,
204 1.0,
205 );
206
207 format!("Handoff complete: {from} → {target}\nSummary: {summary}")
208 }
209
210 "claim" => {
214 let Some(target) = message else {
215 return "Error: message (the claim target, e.g. a file path or task label) is required for claim".to_string();
216 };
217 let agent = current_agent_id.map_or_else(
218 || crate::core::scent_field::scent_agent_id().to_string(),
219 str::to_string,
220 );
221 let normalized = crate::core::pathutil::normalize_tool_path(target);
222 match crate::core::scent_field::claim(&agent, &normalized) {
223 Ok(()) => {
224 format!("Claimed: {normalized} (by {agent}, decays in ~10m unless re-claimed)")
225 }
226 Err(e) => format!("Claim REJECTED: {normalized} — {e}"),
227 }
228 }
229
230 "release" => {
232 let Some(target) = message else {
233 return "Error: message (the claim target) is required for release".to_string();
234 };
235 let agent = current_agent_id.map_or_else(
236 || crate::core::scent_field::scent_agent_id().to_string(),
237 str::to_string,
238 );
239 let normalized = crate::core::pathutil::normalize_tool_path(target);
240 crate::core::scent_field::release(&agent, &normalized);
241 format!("Released: {normalized}")
242 }
243
244 "brief" => {
248 let Some(task) = message else {
249 return "Error: message (the sub-agent task) is required for brief".to_string();
250 };
251 let budget = priority
252 .and_then(|p| p.parse::<usize>().ok())
253 .unwrap_or(2000);
254 let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(project_root)
255 else {
256 return "No knowledge stored for this project yet — a briefing pack needs facts. Use ctx_knowledge(action=\"remember\") first.".to_string();
257 };
258 let pack =
259 crate::core::subagent_contract::build_briefing_pack(&knowledge, task, budget);
260 match crate::core::subagent_contract::serialize_pack(&pack) {
261 Ok(json) => json,
262 Err(e) => format!("Error: {e}"),
263 }
264 }
265
266 "return" => {
270 let Some(report) = message else {
271 return "Error: message (the sub-agent report) is required for return".to_string();
272 };
273 let (facts, rejected) = crate::core::subagent_contract::parse_return_lines(report);
274 if facts.is_empty() {
275 return format!(
276 "No contract-formatted lines found ({} rejected). Expected 'category/key: value' per line.",
277 rejected.len()
278 );
279 }
280
281 let session_label = current_agent_id.unwrap_or("subagent");
282 let policy = match crate::tools::knowledge_shared::load_policy_or_error() {
283 Ok(p) => p,
284 Err(e) => return e,
285 };
286 let count = facts.len();
287 let res = crate::core::knowledge::ProjectKnowledge::mutate_locked(
288 project_root,
289 |knowledge| {
290 for f in &facts {
291 knowledge.remember(
292 &f.category,
293 &f.key,
294 &f.value,
295 session_label,
296 0.8,
297 &policy,
298 );
299 }
300 },
301 );
302 match res {
303 Ok(_) => {
304 let mut out = format!(
305 "Return synthesis: {count} fact(s) distilled into parent knowledge"
306 );
307 if !rejected.is_empty() {
308 out.push_str(&format!(
309 "\n{} line(s) rejected (not 'category/key: value'):",
310 rejected.len()
311 ));
312 for r in rejected.iter().take(5) {
313 out.push_str(&format!("\n ✗ {r}"));
314 }
315 }
316 out
317 }
318 Err(e) => format!("Error: knowledge store update failed: {e}"),
319 }
320 }
321
322 "sync" => {
323 let registry = AgentRegistry::load_or_create();
324 let pending_count = current_agent_id.map_or(0, |id| {
325 registry
326 .scratchpad
327 .iter()
328 .filter(|e| {
329 !e.read_by.contains(&id.to_string())
330 && e.from_agent != id
331 && (e.to_agent.is_none() || e.to_agent.as_deref() == Some(id))
332 })
333 .count()
334 });
335 let agents: Vec<&crate::core::agents::AgentEntry> = registry
336 .agents
337 .iter()
338 .filter(|a| a.status != AgentStatus::Finished && a.project_root == project_root)
339 .collect();
340
341 if agents.is_empty() {
342 return "No active agents to sync with.".to_string();
343 }
344
345 let shared_dir = crate::core::data_dir::lean_ctx_data_dir()
346 .unwrap_or_default()
347 .join("agents")
348 .join("shared");
349
350 let shared_count = if shared_dir.exists() {
351 std::fs::read_dir(&shared_dir).map_or(0, std::iter::Iterator::count)
352 } else {
353 0
354 };
355
356 let mut out = "Multi-Agent Sync Status:\n".to_string();
357 out.push_str(&format!(" Active agents: {}\n", agents.len()));
358 for a in &agents {
359 let role = a.role.as_deref().unwrap_or("-");
360 let age = (chrono::Utc::now() - a.last_active).num_minutes();
361 out.push_str(&format!(
362 " {} [{}] role={} ({}m ago)\n",
363 a.agent_id, a.agent_type, role, age
364 ));
365 }
366 out.push_str(&format!(" Pending messages: {pending_count}\n"));
367 out.push_str(&format!(" Shared contexts: {shared_count}\n"));
368
369 let scents = crate::core::scent_field::sync_block();
372 if !scents.is_empty() {
373 out.push_str(&scents);
374 }
375 out
376 }
377
378 "export" => {
379 let Some(agent_id) = current_agent_id else {
380 return "Error: agent must be registered first (use action=register)".to_string();
381 };
382
383 fn privacy_label(p: &PrivacyLevel) -> &'static str {
384 match p {
385 PrivacyLevel::Public => "public",
386 PrivacyLevel::Team => "team",
387 PrivacyLevel::Private => "private",
388 }
389 }
390
391 fn priority_label(p: &MessagePriority) -> &'static str {
392 match p {
393 MessagePriority::Low => "low",
394 MessagePriority::Normal => "normal",
395 MessagePriority::High => "high",
396 MessagePriority::Critical => "critical",
397 }
398 }
399
400 fn maybe_redact(s: &str, should_redact: bool) -> String {
401 if should_redact {
402 crate::core::redaction::redact_text(s)
403 } else {
404 s.to_string()
405 }
406 }
407
408 #[derive(serde::Serialize)]
409 struct ExportAgentV1 {
410 agent_id: String,
411 agent_type: String,
412 role: Option<String>,
413 status: String,
414 status_message: Option<String>,
415 started_at: String,
416 last_active: String,
417 pid: u32,
418 }
419
420 #[derive(serde::Serialize)]
421 struct ExportMessageV1 {
422 id: String,
423 from_agent: String,
424 to_agent: Option<String>,
425 category: String,
426 privacy: String,
427 priority: String,
428 message: String,
429 metadata: std::collections::BTreeMap<String, String>,
430 timestamp: String,
431 expires_at: Option<String>,
432 read_by_count: usize,
433 }
434
435 #[derive(serde::Serialize)]
436 struct ExportTaskV1 {
437 id: String,
438 from_agent: String,
439 to_agent: String,
440 state: String,
441 description: String,
442 created_at: String,
443 updated_at: String,
444 messages: usize,
445 artifacts: usize,
446 transitions: usize,
447 }
448
449 #[derive(serde::Serialize)]
450 struct ExportDiaryEntryV1 {
451 entry_type: String,
452 content: String,
453 context: Option<String>,
454 timestamp: String,
455 }
456
457 #[derive(serde::Serialize)]
458 struct ExportDiaryV1 {
459 agent_id: String,
460 agent_type: String,
461 project_root: String,
462 updated_at: String,
463 entries: Vec<ExportDiaryEntryV1>,
464 }
465
466 #[derive(serde::Serialize)]
467 struct A2ASnapshotV1 {
468 schema_version: u32,
469 created_at: String,
470 project_root: String,
471 agent_id: String,
472 agents: Vec<ExportAgentV1>,
473 messages: Vec<ExportMessageV1>,
474 tasks: Vec<ExportTaskV1>,
475 diary: Option<ExportDiaryV1>,
476 }
477
478 let privacy_mode = privacy.unwrap_or("redacted");
479 let allow_full = privacy_mode == "full"
480 && !crate::core::redaction::redaction_enabled_for_active_role();
481 let should_redact = !allow_full;
482
483 let now = chrono::Utc::now();
484 let mut registry = AgentRegistry::load_or_create();
485 registry.cleanup_stale(24);
486
487 let mut agents: Vec<ExportAgentV1> = registry
488 .list_active(Some(project_root))
489 .into_iter()
490 .map(|a| ExportAgentV1 {
491 agent_id: a.agent_id.clone(),
492 agent_type: a.agent_type.clone(),
493 role: a.role.clone(),
494 status: a.status.to_string(),
495 status_message: a.status_message.clone(),
496 started_at: a.started_at.to_rfc3339(),
497 last_active: a.last_active.to_rfc3339(),
498 pid: a.pid,
499 })
500 .collect();
501 agents.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
502
503 let mut messages: Vec<ExportMessageV1> = registry
504 .scratchpad
505 .iter()
506 .filter(|e| e.to_agent.is_none() || e.to_agent.as_deref() == Some(agent_id))
507 .take(200)
508 .map(|m| ExportMessageV1 {
509 id: m.id.clone(),
510 from_agent: m.from_agent.clone(),
511 to_agent: m.to_agent.clone(),
512 category: m.category.clone(),
513 privacy: privacy_label(&m.privacy).to_string(),
514 priority: priority_label(&m.priority).to_string(),
515 message: maybe_redact(&m.message, should_redact),
516 metadata: m
517 .metadata
518 .iter()
519 .map(|(k, v)| (k.clone(), maybe_redact(v, should_redact)))
520 .collect(),
521 timestamp: m.timestamp.to_rfc3339(),
522 expires_at: m.expires_at.map(|t| t.to_rfc3339()),
523 read_by_count: m.read_by.len(),
524 })
525 .collect();
526 messages.sort_by(|a, b| a.timestamp.cmp(&b.timestamp).then_with(|| a.id.cmp(&b.id)));
527
528 let mut task_store = TaskStore::load();
529 task_store.cleanup_old(72);
530 let mut tasks: Vec<ExportTaskV1> = task_store
531 .tasks_for_agent(agent_id)
532 .into_iter()
533 .take(200)
534 .map(|t| ExportTaskV1 {
535 id: t.id.clone(),
536 from_agent: t.from_agent.clone(),
537 to_agent: t.to_agent.clone(),
538 state: t.state.to_string(),
539 description: maybe_redact(&t.description, should_redact),
540 created_at: t.created_at.to_rfc3339(),
541 updated_at: t.updated_at.to_rfc3339(),
542 messages: t.messages.len(),
543 artifacts: t.artifacts.len(),
544 transitions: t.history.len(),
545 })
546 .collect();
547 tasks.sort_by(|a, b| {
548 b.updated_at
549 .cmp(&a.updated_at)
550 .then_with(|| a.id.cmp(&b.id))
551 });
552
553 let diary = AgentDiary::load(agent_id).map(|d| ExportDiaryV1 {
554 agent_id: d.agent_id,
555 agent_type: d.agent_type,
556 project_root: d.project_root,
557 updated_at: d.updated_at.to_rfc3339(),
558 entries: d
559 .entries
560 .iter()
561 .rev()
562 .take(25)
563 .rev()
564 .map(|e| ExportDiaryEntryV1 {
565 entry_type: e.entry_type.to_string(),
566 content: maybe_redact(&e.content, should_redact),
567 context: e.context.as_deref().map(|c| maybe_redact(c, should_redact)),
568 timestamp: e.timestamp.to_rfc3339(),
569 })
570 .collect(),
571 });
572
573 let payload = A2ASnapshotV1 {
574 schema_version: crate::core::contracts::A2A_SNAPSHOT_V1_SCHEMA_VERSION,
575 created_at: now.to_rfc3339(),
576 project_root: project_root.to_string(),
577 agent_id: agent_id.to_string(),
578 agents,
579 messages,
580 tasks,
581 diary,
582 };
583
584 let json = serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string());
585
586 if write {
587 let proofs_dir = std::path::Path::new(project_root)
588 .join(".lean-ctx")
589 .join("proofs");
590 if let Err(e) = std::fs::create_dir_all(&proofs_dir) {
591 return format!("Error: create proofs dir: {e}");
592 }
593
594 let name = if let Some(f) = filename {
595 let p = std::path::Path::new(f);
596 if p.components().count() != 1 {
597 return "Error: filename must be a plain file name (no directories)"
598 .to_string();
599 }
600 f.to_string()
601 } else {
602 format!("a2a-snapshot-v1_{}.json", now.format("%Y%m%d_%H%M%S"))
603 };
604
605 let out_path = proofs_dir.join(name);
606 if let Err(e) = std::fs::write(&out_path, &json) {
607 return format!("Error: write snapshot: {e}");
608 }
609
610 let mut ledger = EvidenceLedgerV1::load();
611 if let Err(e) = ledger.record_artifact_file(
612 "proof:a2a-snapshot-v1",
613 &out_path,
614 chrono::Utc::now(),
615 ) {
616 return format!("Snapshot written but evidence ledger record failed: {e}");
617 }
618 if let Err(e) = ledger.save() {
619 return format!("Snapshot written but evidence ledger save failed: {e}");
620 }
621
622 return format!(
623 "A2A snapshot exported: {}\n agents: {}\n messages: {}\n tasks: {}",
624 out_path.display(),
625 payload.agents.len(),
626 payload.messages.len(),
627 payload.tasks.len()
628 );
629 }
630
631 match format.unwrap_or("json") {
632 "text" => format!(
633 "A2A snapshot (v1)\n agents: {}\n messages: {}\n tasks: {}",
634 payload.agents.len(),
635 payload.messages.len(),
636 payload.tasks.len()
637 ),
638 _ => json,
639 }
640 }
641
642 "diary" => {
643 let Some(agent_id) = current_agent_id else {
644 return "Error: agent must be registered first".to_string();
645 };
646 let Some(content) = message else {
647 return "Error: message is required for diary entry".to_string();
648 };
649 let entry_type = match category.unwrap_or("progress") {
650 "discovery" | "found" => DiaryEntryType::Discovery,
651 "decision" | "decided" => DiaryEntryType::Decision,
652 "blocker" | "blocked" => DiaryEntryType::Blocker,
653 "progress" | "done" => DiaryEntryType::Progress,
654 "insight" => DiaryEntryType::Insight,
655 other => {
656 return format!(
657 "Unknown diary type: {other}. Use: discovery, decision, blocker, progress, insight"
658 );
659 }
660 };
661 let atype = agent_type.unwrap_or("unknown");
662 let mut diary = AgentDiary::load_or_create(agent_id, atype, project_root);
663 let context_str = to_agent;
664 diary.add_entry(entry_type.clone(), content, context_str);
665 match diary.save() {
666 Ok(()) => format!("Diary entry [{entry_type}] added: {content}"),
667 Err(e) => format!("Diary entry added but save failed: {e}"),
668 }
669 }
670
671 "recall_diary" | "diary_recall" => {
672 let Some(agent_id) = current_agent_id else {
673 let diaries = AgentDiary::list_all();
674 if diaries.is_empty() {
675 return "No agent diaries found.".to_string();
676 }
677 let mut out = format!("Agent Diaries ({}):\n", diaries.len());
678 for (id, count, updated) in &diaries {
679 let age = (chrono::Utc::now() - *updated).num_minutes();
680 out.push_str(&format!(" {id}: {count} entries ({age}m ago)\n"));
681 }
682 return out;
683 };
684 match AgentDiary::load(agent_id) {
685 Some(diary) => diary.format_summary(),
686 None => format!("No diary found for agent '{agent_id}'."),
687 }
688 }
689
690 "diaries" => {
691 let diaries = AgentDiary::list_all();
692 if diaries.is_empty() {
693 return "No agent diaries found.".to_string();
694 }
695 let mut out = format!("Agent Diaries ({}):\n", diaries.len());
696 for (id, count, updated) in &diaries {
697 let age = (chrono::Utc::now() - *updated).num_minutes();
698 out.push_str(&format!(" {id}: {count} entries ({age}m ago)\n"));
699 }
700 out
701 }
702
703 "share_knowledge" => {
704 let cat = category.unwrap_or("general");
705 let Some(msg_text) = message else {
706 return "Error: message required (format: key1=value1;key2=value2)".to_string();
707 };
708 let facts: Vec<(String, String)> = msg_text
709 .split(';')
710 .filter_map(|kv| {
711 let (k, v) = kv.split_once('=')?;
712 Some((k.trim().to_string(), v.trim().to_string()))
713 })
714 .collect();
715 if facts.is_empty() {
716 return "Error: no valid key=value pairs found".to_string();
717 }
718 let from = current_agent_id.unwrap_or("anonymous");
719 match AgentRegistry::mutate_locked(|registry| {
720 registry.share_knowledge(from, cat, &facts);
721 }) {
722 Ok(_) => format!("Shared {} facts in category '{}'", facts.len(), cat),
723 Err(e) => format!("Share failed: {e}"),
724 }
725 }
726
727 "receive_knowledge" => {
728 let Some(agent_id) = current_agent_id else {
729 return "Error: agent must be registered first".to_string();
730 };
731 let facts = AgentRegistry::mutate_locked(|registry| {
732 registry.receive_shared_knowledge(agent_id)
733 })
734 .map(|(_, facts)| facts)
735 .unwrap_or_default();
736 if facts.is_empty() {
737 return "No new shared knowledge.".to_string();
738 }
739 let mut out = format!("Received {} facts:\n", facts.len());
740 for f in &facts {
741 let age = (chrono::Utc::now() - f.timestamp).num_minutes();
742 out.push_str(&format!(
743 " [{}] {}={} (from {}, {}m ago)\n",
744 f.category, f.key, f.value, f.from_agent, age
745 ));
746 }
747 out
748 }
749
750 "poll_events" => {
751 let Some(agent_id) = current_agent_id else {
752 return "Error: agent must be registered first".to_string();
753 };
754 let workspace_id = to_agent.unwrap_or(project_root);
755 let channel_id = category.unwrap_or("default");
756 let since_id: i64 = message.and_then(|s| s.parse().ok()).unwrap_or(0);
757 let limit: usize = _ttl_hours.unwrap_or(50) as usize;
758
759 let rt = crate::core::context_os::runtime();
760 let events = rt.bus.read(workspace_id, channel_id, since_id, limit);
761
762 let filter = crate::core::context_os::TopicFilter {
763 agent_id: Some(agent_id.to_string()),
764 kinds: privacy.and_then(|s| {
765 let kinds: Vec<_> = s
766 .split(',')
767 .map(|k| crate::core::context_os::ContextEventKindV1::parse(k.trim()))
768 .collect();
769 if kinds.is_empty() { None } else { Some(kinds) }
770 }),
771 ..Default::default()
772 };
773
774 let filtered: Vec<_> = events.into_iter().filter(|e| filter.matches(e)).collect();
775 if filtered.is_empty() {
776 return format!("No new events since id={since_id} for {agent_id}.");
777 }
778
779 let mut out = format!("Events ({}, since={since_id}):\n", filtered.len());
780 for ev in &filtered {
781 let actor = ev.actor.as_deref().unwrap_or("-");
782 out.push_str(&format!(
783 " #{} [{}] actor={} cl={} ({})\n",
784 ev.id,
785 ev.kind,
786 actor,
787 ev.consistency_level,
788 ev.timestamp.format("%H:%M:%S")
789 ));
790 }
791 if let Some(last) = filtered.last() {
792 out.push_str(&format!("cursor={}", last.id));
793 }
794 out
795 }
796
797 "lease_acquire" => {
799 let Some(agent_id) = current_agent_id else {
800 return "Error: agent must be registered first".to_string();
801 };
802 let Some(target) = message else {
803 return "Error: message (path or symbol:<name>) is required".to_string();
804 };
805 let (resource_kind, resource_ref) = lease_resource(target);
806 let duration_ms = match _ttl_hours.unwrap_or(0) {
807 0 => 10 * 60 * 1_000,
808 1 => 60 * 60 * 1_000,
809 _ => return "Error: ttl_hours for lease must be 0 (10min) or 1 (1h)".to_string(),
810 };
811 let request = crate::core::agent_lease::AgentLeaseRequestV1 {
812 schema_version: crate::core::agent_lease::AGENT_LEASE_SCHEMA_VERSION,
813 lease_request_ref: format!(
814 "lease-request:{}",
815 blake3::hash(format!("{agent_id}:{resource_ref}").as_bytes()).to_hex()
816 ),
817 resource_kind,
818 resource_ref: resource_ref.clone(),
819 owner_agent_id: agent_id.to_string(),
820 duration_ms,
821 };
822 match crate::core::agent_lease::acquire_local(request) {
823 Ok(crate::core::agent_lease::AgentLeaseAcquireV1::Granted(lease)) => format!(
824 "Lease granted: {} resource={} owner={} expires_at_epoch_ms={}",
825 lease.lease_ref, resource_ref, agent_id, lease.expires_at_epoch_ms
826 ),
827 Ok(crate::core::agent_lease::AgentLeaseAcquireV1::HeldBy {
828 owner_agent_id,
829 lease_ref,
830 expires_at_epoch_ms,
831 }) => format!(
832 "Lease HELD: resource={resource_ref} owner={owner_agent_id} lease_ref={lease_ref} expires={expires_at_epoch_ms}"
833 ),
834 Err(e) => format!("Lease rejected: {e}"),
835 }
836 }
837
838 "lease_release" => {
840 let Some(agent_id) = current_agent_id else {
841 return "Error: agent must be registered first".to_string();
842 };
843 let Some(target) = message else {
844 return "Error: message (same path or symbol:<name>) is required".to_string();
845 };
846 let Some(lease_ref) = category else {
847 return "Error: category (lease_ref from lease_acquire) is required".to_string();
848 };
849 let (resource_kind, resource_ref) = lease_resource(target);
850 match crate::core::agent_lease::release_local(
851 resource_kind,
852 &resource_ref,
853 agent_id,
854 lease_ref,
855 ) {
856 Ok(true) => format!("Lease released: {lease_ref} resource={resource_ref}"),
857 Ok(false) => format!("No active lease found for resource={resource_ref}"),
858 Err(e) => format!("Release failed: {e}"),
859 }
860 }
861
862 _ => format!(
863 "Unknown action: {action}. Use: register, list, post, read, status, info, handoff, sync, poll_events, diary, recall_diary, diaries, share_knowledge, receive_knowledge, lease_acquire, lease_release"
864 ),
865 }
866}
867
868fn lease_resource(target: &str) -> (crate::core::agent_lease::AgentLeaseResourceKindV1, String) {
870 if let Some(symbol) = target.strip_prefix("symbol:") {
871 (
872 crate::core::agent_lease::AgentLeaseResourceKindV1::Symbol,
873 format!("symbolref:{}", blake3::hash(symbol.as_bytes()).to_hex()),
874 )
875 } else {
876 let normalized = crate::core::pathutil::normalize_tool_path(target);
877 (
878 crate::core::agent_lease::AgentLeaseResourceKindV1::Path,
879 format!("pathref:{}", blake3::hash(normalized.as_bytes()).to_hex()),
880 )
881 }
882}