1use crate::proc_tree::Proc;
18use serde_json::Value;
19use std::collections::BTreeSet;
20use std::path::{Path, PathBuf};
21
22const CALL_TTL_MS: u64 = 600_000;
25
26const MCP_CALL_TTL_MS: u64 = 30_000;
28
29#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
30struct Call {
31 session: String,
32 agent: String,
33 argv: Vec<String>,
34 at: u64,
35 #[serde(default, skip_serializing_if = "Vec::is_empty")]
37 tree: Vec<Proc>,
38}
39
40fn root() -> PathBuf {
41 crate::runtime_dir().join("agent-calls")
42}
43
44pub(crate) fn token(raw: &str) -> String {
47 raw.trim()
48 .chars()
49 .map(|c| {
50 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_') {
51 c
52 } else {
53 '_'
54 }
55 })
56 .collect()
57}
58
59fn now_ms() -> u64 {
60 std::time::SystemTime::now()
61 .duration_since(std::time::UNIX_EPOCH)
62 .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
63}
64
65fn shell_dir(root: &Path, session: &str) -> PathBuf {
66 root.join("shell").join(token(session))
67}
68
69fn mcp_path(root: &Path, call: &str) -> PathBuf {
70 root.join("mcp").join(format!("{call}.json"))
71}
72
73fn put(path: &Path, call: &Call) {
74 let Some(dir) = path.parent() else {
75 return;
76 };
77 let Ok(text) = serde_json::to_string(call) else {
78 return;
79 };
80 if std::fs::create_dir_all(dir).is_err() {
81 return;
82 }
83 let tmp = path.with_extension(format!("tmp{}", std::process::id()));
84 if std::fs::write(&tmp, text).is_ok() && std::fs::rename(&tmp, path).is_err() {
85 let _ = std::fs::remove_file(&tmp);
86 }
87}
88
89fn read(path: &Path) -> Option<Call> {
90 serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
91}
92
93const TAKEN: &str = "taken";
97
98fn waiting(dir: &Path, now: u64) -> Vec<(PathBuf, Call)> {
101 listed(dir, now, "json", CALL_TTL_MS)
102}
103
104fn waiting_mcp(dir: &Path, now: u64) -> Vec<(PathBuf, Call)> {
106 listed(dir, now, "json", MCP_CALL_TTL_MS)
107}
108
109fn spent(dir: &Path, now: u64) -> Vec<(PathBuf, Call)> {
111 listed(dir, now, TAKEN, CALL_TTL_MS)
112}
113
114fn listed(dir: &Path, now: u64, ext: &str, ttl: u64) -> Vec<(PathBuf, Call)> {
115 let Ok(entries) = std::fs::read_dir(dir) else {
116 return Vec::new();
117 };
118 let mut out = Vec::new();
119 for path in entries.flatten().map(|e| e.path()) {
120 if path.extension().is_none_or(|e| e != ext) {
121 continue;
122 }
123 let Some(call) = read(&path) else {
124 continue;
125 };
126 if now.saturating_sub(call.at) > ttl {
127 let _ = std::fs::remove_file(&path);
128 continue;
129 }
130 out.push((path, call));
131 }
132 out
133}
134
135fn text(v: &Value, keys: &[&str]) -> Option<String> {
136 keys.iter()
137 .find_map(|k| v[*k].as_str())
138 .map(str::trim)
139 .filter(|s| !s.is_empty())
140 .map(str::to_string)
141}
142
143#[must_use]
146pub fn mcp_prefixed_tool(tool: &str) -> Option<(&str, &str)> {
147 let rest = tool.strip_prefix("mcp__")?;
148 let (server, name) = rest.rsplit_once("__")?;
149 if server.is_empty() || name.is_empty() {
150 None
151 } else {
152 Some((server, name))
153 }
154}
155
156#[must_use]
161pub fn is_seat_mcp_tool(tool: &str) -> bool {
162 is_confirmed_seat_call(tool, None, None)
163}
164
165pub(crate) fn is_confirmed_seat_call(
169 tool: &str,
170 field: Option<&str>,
171 command: Option<&str>,
172) -> bool {
173 if let Some(command) = command.map(str::trim).filter(|text| !text.is_empty()) {
177 if !crate::cloud::payload_command_is_seat(command) {
178 return false;
179 }
180 }
181 match crate::cloud::seat_mcp_keys() {
182 crate::cloud::SeatMcpKeys::Known(keys) => seat_call_matches(tool, field, &keys),
183 crate::cloud::SeatMcpKeys::Unknown | crate::cloud::SeatMcpKeys::Ambiguous => false,
184 }
185}
186
187pub(crate) fn seat_call_matches(tool: &str, field: Option<&str>, keys: &BTreeSet<String>) -> bool {
188 if let Some((embedded, short)) = mcp_prefixed_tool(tool) {
189 if !short.starts_with("ljos_") {
190 return false;
191 }
192 if field.is_some_and(|name| name != embedded) {
193 return false;
194 }
195 return keys.contains(embedded);
196 }
197 if !tool.starts_with("ljos_") {
198 return false;
199 }
200 field.is_some_and(|name| keys.contains(name))
201}
202
203fn seat_shaped_mcp_tool(tool: &str) -> bool {
207 mcp_prefixed_tool(tool).is_some_and(|(_, name)| name.starts_with("ljos_"))
208}
209
210fn is_any_seat_mcp_tool(tool: &str) -> bool {
211 let tool = tool.trim();
212 seat_shaped_mcp_tool(tool)
213 || tool.starts_with("ljos_")
214 || tool
215 .strip_prefix("MCP:")
216 .is_some_and(|rest| rest.starts_with("ljos_"))
217}
218
219pub fn payload_names_mcp_tool(input: &str) -> bool {
222 let Ok(v) = serde_json::from_str::<Value>(input.trim()) else {
223 return false;
224 };
225 text(&v, &["tool_name", "toolName"])
226 .is_some_and(|name| name.starts_with("mcp__") || name.starts_with("MCP:"))
227}
228
229pub fn note_hook_call(input: &str, event: &str) -> bool {
238 let Ok(v) = serde_json::from_str::<Value>(input.trim()) else {
239 return false;
240 };
241 let agent = text(&v, &["agent_id", "agentId"]).map(|a| crate::ballot_sig::field(&a));
242 crate::ballot_sig::hook_agent(agent.clone());
243 let tool = text(&v, &["tool_name", "toolName"]).unwrap_or_default();
244 let field = ["mcp_server_name", "mcpServerName", "server_name", "server"]
245 .iter()
246 .find_map(|key| v[*key].as_str())
247 .map(str::trim)
248 .filter(|name| !name.is_empty());
249 let command = text(&v, &["command"]);
250 let shaped = event == "PreToolUse" && seat_shaped_mcp_tool(&tool);
251 let seat_tool = shaped && is_confirmed_seat_call(&tool, field, command.as_deref());
252 let any_mcp_tool = matches!(event, "PreToolUse" | "preToolUse" | "beforeMCPExecution")
253 && is_any_seat_mcp_tool(&tool);
254 let session = crate::hook_call_as(input, Some(event))
255 .session
256 .map(|s| crate::ballot_sig::field(&s));
257 let call = text(&v, &["tool_use_id", "toolUseId"])
258 .map(|c| token(&c))
259 .filter(|c| !c.is_empty());
260 if any_mcp_tool {
261 if let Some(ref s) = session {
262 if let Some(id) = crate::usable_session_id(s) {
263 let root = root();
264 let tree = crate::proc_tree::ancestors();
265 let found = Call {
266 session: id.to_string(),
267 agent: agent.clone().unwrap_or_default(),
268 argv: Vec::new(),
269 at: now_ms(),
270 tree,
271 };
272 if let Some(ref c) = call {
273 put(&mcp_path(&root, c), &found);
274 } else if !crate::subagent_id(id) && agent.is_none() {
275 put(
276 &root.join("mcp").join(format!("latest-{}.json", now_ms())),
277 &found,
278 );
279 }
280 }
281 }
282 }
283 if matches!(event, "PreToolUse" | "preToolUse" | "beforeShellExecution") {
284 if let Some(ref s) = session {
285 if crate::usable_session_id(s).is_some_and(|id| !crate::subagent_id(id)) {
286 let input_obj = if v["tool_input"].is_object() {
287 &v["tool_input"]
288 } else {
289 &v["toolInput"]
290 };
291 let line = input_obj["command"]
292 .as_str()
293 .or_else(|| v["command"].as_str())
294 .unwrap_or("");
295 crate::note_tmux_spawn(line, v["cwd"].as_str(), s);
296 }
297 }
298 }
299 let (Some(session), Some(call)) = (session, call) else {
300 return seat_tool;
301 };
302 let root = root();
303 match event {
304 "PreToolUse" if shaped => {}
305 "PreToolUse" => {
306 let input = if v["tool_input"].is_object() {
307 &v["tool_input"]
308 } else {
309 &v["toolInput"]
310 };
311 let Some(line) = input["command"].as_str() else {
312 return false;
313 };
314 let at = now_ms();
315 let dir = shell_dir(&root, &session);
316 let tree = crate::proc_tree::ancestors();
317 for (i, argv) in crate::ljos_invocations(line).into_iter().enumerate() {
318 let found = Call {
319 session: session.clone(),
320 agent: agent.clone().unwrap_or_default(),
321 argv,
322 at,
323 tree: tree.clone(),
324 };
325 put(&dir.join(format!("{call}.{i}.json")), &found);
326 }
327 }
328 "PostToolUse" | "PostToolUseFailure" => forget(&root, &session, &call),
329 _ => {}
330 }
331 seat_tool
332}
333
334fn forget(root: &Path, session: &str, call: &str) {
335 let _ = std::fs::remove_file(mcp_path(root, call));
336 let Ok(entries) = std::fs::read_dir(shell_dir(root, session)) else {
337 return;
338 };
339 let prefix = format!("{call}.");
340 for entry in entries.flatten() {
341 let name = entry.file_name();
342 let name = name.to_string_lossy();
343 if name.starts_with(&prefix) && !name.ends_with(&format!(".{TAKEN}")) {
344 let _ = std::fs::remove_file(entry.path());
345 }
346 }
347}
348
349fn argv_matches(written: &[String], argv: &[String]) -> bool {
352 written.len() == argv.len()
353 && written
354 .iter()
355 .zip(argv)
356 .all(|(w, a)| w == a || w.contains(['$', '`', '*', '?', '[', '~']))
357}
358
359fn harness_of<'a>(call: &'a Call, mine: &[Proc]) -> Option<&'a Proc> {
363 let nearest = call.tree.iter().find(|p| crate::proc_tree::alive(p))?;
364 mine.iter().any(|m| m.same(nearest)).then_some(nearest)
365}
366
367#[derive(Debug, Clone, PartialEq, Eq)]
370pub struct Taken {
371 pub session: String,
372 pub agent: String,
373 pub harness: Proc,
375}
376
377static THIS_CALL: std::sync::OnceLock<Option<Taken>> = std::sync::OnceLock::new();
378
379fn this_call(session: Option<&str>) -> Option<Taken> {
384 THIS_CALL
385 .get_or_init(|| {
386 let mine = crate::proc_tree::ancestors();
387 claim_in(&root(), session, &this_line()?, now_ms(), &mine)
388 })
389 .clone()
390}
391
392#[must_use]
394pub fn taken_harness() -> Option<Proc> {
395 THIS_CALL.get()?.as_ref().map(|t| t.harness.clone())
396}
397
398#[must_use]
403pub fn claim_command(session: &str) -> Option<String> {
404 this_call(Some(session))
405 .filter(|t| t.session == session)
406 .map(|t| t.agent)
407}
408
409#[must_use]
415pub fn claim_unsessioned_command() -> Option<(String, String)> {
416 this_call(None).map(|t| (t.session, t.agent))
417}
418
419#[must_use]
422pub fn runs_as_ljos() -> bool {
423 this_line().is_some()
424}
425
426fn this_line() -> Option<Vec<String>> {
429 let mut args = std::env::args_os();
430 let program = PathBuf::from(args.next()?);
431 (program.file_name()? == "ljos")
432 .then(|| args.map(|a| a.to_string_lossy().into_owned()).collect())
433}
434
435fn claim_in(
436 root: &Path,
437 session: Option<&str>,
438 argv: &[String],
439 now: u64,
440 mine: &[Proc],
441) -> Option<Taken> {
442 let fits = |c: &Call| argv_matches(&c.argv, argv) && harness_of(c, mine).is_some();
443 let own =
444 session.and_then(|s| claim_from(&[shell_dir(root, s)], now, |c| c.session == s && fits(c)));
445 let call = own.or_else(|| {
446 let dirs: Vec<PathBuf> = std::fs::read_dir(root.join("shell"))
447 .ok()?
448 .flatten()
449 .map(|d| d.path())
450 .collect();
451 claim_from(&dirs, now, fits)
452 })?;
453 let harness = harness_of(&call, mine)?.clone();
454 Some(Taken {
455 session: call.session,
456 agent: call.agent,
457 harness,
458 })
459}
460
461fn claim_from(dirs: &[PathBuf], now: u64, fits: impl Fn(&Call) -> bool) -> Option<Call> {
465 let among = |list: fn(&Path, u64) -> Vec<(PathBuf, Call)>| -> Vec<(PathBuf, Call)> {
466 let mut found: Vec<_> = dirs
467 .iter()
468 .flat_map(|d| list(d, now))
469 .filter(|(_, c)| fits(c))
470 .collect();
471 found.sort_by(|a, b| b.1.at.cmp(&a.1.at).then_with(|| b.0.cmp(&a.0)));
472 found
473 };
474 take_newest(among(waiting)).or_else(|| among(spent).into_iter().next().map(|(_, c)| c))
475}
476
477fn take_newest(found: Vec<(PathBuf, Call)>) -> Option<Call> {
480 found.into_iter().find_map(|(path, c)| {
481 std::fs::rename(&path, path.with_extension(TAKEN))
482 .is_ok()
483 .then_some(c)
484 })
485}
486
487#[must_use]
490pub fn claim_mcp_call(call: &str) -> Option<(String, String)> {
491 claim_mcp_in(&root(), call, now_ms())
492}
493
494fn claim_mcp_in(root: &Path, call: &str, now: u64) -> Option<(String, String)> {
495 let call = token(call);
496 if call.is_empty() {
497 return None;
498 }
499 let path = mcp_path(root, &call);
500 let found = waiting_mcp(&root.join("mcp"), now)
501 .into_iter()
502 .find(|(p, _)| *p == path)?
503 .1;
504 std::fs::remove_file(&path).ok()?;
505 Some((found.session, found.agent))
506}
507
508#[must_use]
512pub fn claim_recent_mcp_session() -> Option<String> {
513 recent_mcp_session(true)
514}
515
516#[must_use]
519pub fn peek_recent_mcp_session() -> Option<String> {
520 recent_mcp_session(false)
521}
522
523fn recent_mcp_session(consume: bool) -> Option<String> {
524 let dir = root().join("mcp");
525 let now = now_ms();
526 let mut found: Vec<_> = waiting_mcp(&dir, now)
527 .into_iter()
528 .filter(|(p, c)| {
529 p.file_name()
530 .is_some_and(|n| n.to_string_lossy().starts_with("latest-"))
531 && c.agent.is_empty()
532 && !crate::subagent_id(&c.session)
533 })
534 .collect();
535 let first_session = found.first()?.1.session.clone();
536 if found.iter().any(|(_, c)| c.session != first_session) {
537 return None;
538 }
539 found.sort_by(|a, b| b.1.at.cmp(&a.1.at).then_with(|| b.0.cmp(&a.0)));
540 if !consume {
541 return Some(first_session);
542 }
543 found
544 .into_iter()
545 .find_map(|(path, c)| std::fs::remove_file(&path).ok().map(|()| c.session))
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551
552 fn claim_command_in(
553 root: &Path,
554 session: &str,
555 argv: &[String],
556 now: u64,
557 mine: &[Proc],
558 ) -> Option<String> {
559 claim_in(root, Some(session), argv, now, mine)
560 .filter(|t| t.session == session)
561 .map(|t| t.agent)
562 }
563
564 fn claim_any_in(
565 root: &Path,
566 argv: &[String],
567 now: u64,
568 mine: &[Proc],
569 ) -> Option<(String, String)> {
570 claim_in(root, None, argv, now, mine).map(|t| (t.session, t.agent))
571 }
572
573 fn words(line: &str) -> Vec<String> {
574 line.split_whitespace().map(str::to_string).collect()
575 }
576
577 fn call(session: &str, agent: &str, argv: &str, at: u64) -> Call {
579 Call {
580 session: session.into(),
581 agent: agent.into(),
582 argv: words(argv),
583 at,
584 tree: crate::proc_tree::ancestors(),
585 }
586 }
587
588 #[test]
589 fn a_line_matches_its_argv_and_an_expanded_word_matches_any() {
590 let argv = words("vote demo-a --for ship --used none");
591 assert!(argv_matches(&argv, &argv));
592 assert!(argv_matches(
593 &words("vote $ISSUE --for ship --used none"),
594 &argv
595 ));
596 assert!(!argv_matches(
597 &words("vote demo-a --for hold --used none"),
598 &argv
599 ));
600 assert!(!argv_matches(&words("vote demo-a --for ship"), &argv));
601 }
602
603 #[test]
604 fn the_newest_matching_call_is_taken_once_and_a_stale_one_is_dropped() {
605 let dir = tempfile::tempdir().unwrap();
606 let shell = shell_dir(dir.path(), "sess-1");
607 let line = "vote demo-a --for ship --used none";
608 put(
609 &shell.join("toolu_old.0.json"),
610 &call("sess-1", "agent-b", line, 1_000),
611 );
612 put(
613 &shell.join("toolu_new.0.json"),
614 &call("sess-1", "", line, 2_000),
615 );
616 put(
617 &shell.join("toolu_sub.0.json"),
618 &call("sess-1", "agent-a", line, 1_500),
619 );
620 put(
621 &shell.join("toolu_other.0.json"),
622 &call("sess-1", "agent-c", "recall demo-a", 2_500),
623 );
624 let argv = words(line);
625 let now = 3_000;
626 let mine = crate::proc_tree::ancestors();
627 assert_eq!(
628 claim_command_in(dir.path(), "sess-1", &argv, now, &mine).as_deref(),
629 Some("")
630 );
631 assert_eq!(
632 claim_command_in(dir.path(), "sess-1", &argv, now, &mine).as_deref(),
633 Some("agent-a")
634 );
635 assert_eq!(
636 claim_command_in(dir.path(), "sess-2", &argv, now, &mine),
637 None,
638 "another session's call names no agent of this one"
639 );
640 let late = 2_000 + CALL_TTL_MS + 1;
641 assert_eq!(
642 claim_command_in(dir.path(), "sess-1", &argv, late, &mine),
643 None
644 );
645 assert!(!shell.join("toolu_old.0.json").exists());
646 assert!(!shell.join("toolu_new.0.taken").exists());
647 }
648
649 #[test]
650 fn a_process_with_no_session_takes_the_newest_call_of_any_session() {
651 let dir = tempfile::tempdir().unwrap();
652 let line = "vote demo-a --for ship --used none --as reviewer";
653 put(
654 &shell_dir(dir.path(), "sess-1").join("toolu_a.0.json"),
655 &call("sess-1", "agent-a", line, 1_000),
656 );
657 put(
658 &shell_dir(dir.path(), "sess-2").join("toolu_b.0.json"),
659 &call("sess-2", "agent-b", line, 2_000),
660 );
661 put(
662 &shell_dir(dir.path(), "sess-2").join("toolu_c.0.json"),
663 &call("sess-2", "", "recall demo-a", 3_000),
664 );
665 let argv = words(line);
666 let now = 4_000;
667 let mine = crate::proc_tree::ancestors();
668 assert_eq!(
669 claim_any_in(dir.path(), &argv, now, &mine),
670 Some(("sess-2".into(), "agent-b".into()))
671 );
672 assert_eq!(
673 claim_any_in(dir.path(), &argv, now, &mine),
674 Some(("sess-1".into(), "agent-a".into()))
675 );
676 assert_eq!(
677 claim_any_in(dir.path(), &argv, 2_000 + CALL_TTL_MS + 1, &mine),
678 None
679 );
680 assert_eq!(
681 claim_any_in(
682 dir.path(),
683 &words("recall demo-a"),
684 3_000 + CALL_TTL_MS + 1,
685 &mine
686 ),
687 None
688 );
689 let empty = tempfile::tempdir().unwrap();
690 assert_eq!(claim_any_in(empty.path(), &argv, now, &mine), None);
691 }
692
693 #[test]
694 fn a_line_run_again_binds_what_its_first_run_took() {
695 let dir = tempfile::tempdir().unwrap();
696 let shell = shell_dir(dir.path(), "sess-1");
697 let line = "vote demo-a --for ship --used none --as reviewer";
698 let argv = words(line);
699 let mine = crate::proc_tree::ancestors();
700 put(
701 &shell.join("toolu_a.0.json"),
702 &call("sess-1", "agent-a", line, 1_000),
703 );
704 let first = Some(("sess-1".to_string(), "agent-a".to_string()));
705 assert_eq!(claim_any_in(dir.path(), &argv, 2_000, &mine), first);
706 forget(dir.path(), "sess-1", "toolu_a");
707 assert!(shell.join("toolu_a.0.taken").exists());
708 assert_eq!(claim_any_in(dir.path(), &argv, 2_500, &mine), first);
709 assert_eq!(
710 claim_command_in(dir.path(), "sess-1", &argv, 2_500, &mine).as_deref(),
711 Some("agent-a")
712 );
713 put(
714 &shell.join("toolu_b.0.json"),
715 &call("sess-1", "", line, 1_500),
716 );
717 assert_eq!(
718 claim_command_in(dir.path(), "sess-1", &argv, 2_500, &mine).as_deref(),
719 Some(""),
720 "a call still waiting comes before one already taken"
721 );
722 let mut elsewhere = call("sess-1", "agent-x", line, 1_800);
723 elsewhere.tree.clear();
724 put(&shell.join("toolu_x.0.taken"), &elsewhere);
725 assert_eq!(
726 claim_command_in(dir.path(), "sess-1", &argv, 2_500, &mine).as_deref(),
727 Some(""),
728 "a taken call names no session unless its hook ran above this one"
729 );
730 }
731
732 #[test]
733 fn a_line_whose_environment_names_another_session_still_finds_its_harness() {
734 let dir = tempfile::tempdir().unwrap();
735 let line = "vote demo-a --for ship --used none --as reviewer";
736 let argv = words(line);
737 let mine = crate::proc_tree::ancestors();
738 put(
739 &shell_dir(dir.path(), "sess-hook").join("toolu_h.0.json"),
740 &call("sess-hook", "", line, 1_000),
741 );
742 let taken = claim_in(dir.path(), Some("sess-env"), &argv, 2_000, &mine).unwrap();
743 assert_eq!(taken.session, "sess-hook");
744 assert!(taken.harness.same(&mine[0]));
745 assert_eq!(
746 claim_command_in(dir.path(), "sess-env", &argv, 2_000, &mine),
747 None,
748 "the hook's session names no agent of the environment's"
749 );
750 }
751
752 #[test]
753 fn a_call_whose_hook_ran_in_another_tree_is_not_taken() {
754 let dir = tempfile::tempdir().unwrap();
755 let line = "vote demo-a --for ship --used none --as reviewer";
756 let argv = words(line);
757 let mine = crate::proc_tree::ancestors();
758 let mut sibling = std::process::Command::new("sleep")
759 .arg("30")
760 .spawn()
761 .unwrap();
762 let reused = crate::proc_tree::Proc {
763 start: mine[0].start + 1,
764 ..mine[0].clone()
765 };
766 let forged = [
767 ("toolu_bare", Vec::new()),
768 (
769 "toolu_sibling",
770 vec![crate::proc_tree::of(sibling.id()).unwrap()],
771 ),
772 ("toolu_gone", vec![reused]),
773 ];
774 for (id, tree) in forged {
775 let mut c = call("sess-1", "agent-forged", line, 1_000);
776 c.tree = tree;
777 put(
778 &shell_dir(dir.path(), "sess-1").join(format!("{id}.0.json")),
779 &c,
780 );
781 }
782 assert_eq!(claim_any_in(dir.path(), &argv, 2_000, &mine), None);
783 assert_eq!(
784 claim_command_in(dir.path(), "sess-1", &argv, 2_000, &mine),
785 None
786 );
787 sibling.kill().unwrap();
788 sibling.wait().unwrap();
789 put(
790 &shell_dir(dir.path(), "sess-1").join("toolu_real.0.json"),
791 &call("sess-1", "agent-a", line, 1_500),
792 );
793 assert_eq!(
794 claim_any_in(dir.path(), &argv, 2_000, &mine),
795 Some(("sess-1".into(), "agent-a".into()))
796 );
797 }
798
799 #[test]
800 fn an_mcp_call_names_its_subagent_once() {
801 let dir = tempfile::tempdir().unwrap();
802 put(
803 &mcp_path(dir.path(), "toolu_a"),
804 &call("sess-1", "agent-a", "", 1_000),
805 );
806 assert_eq!(
807 claim_mcp_in(dir.path(), "toolu_a", 2_000),
808 Some(("sess-1".into(), "agent-a".into()))
809 );
810 assert_eq!(claim_mcp_in(dir.path(), "toolu_a", 2_000), None);
811 assert_eq!(claim_mcp_in(dir.path(), "toolu_b", 2_000), None);
812 put(
813 &mcp_path(dir.path(), "toolu_c"),
814 &call("sess-1", "agent-c", "", 1_000),
815 );
816 assert_eq!(claim_mcp_in(dir.path(), "../toolu_c", 2_000), None);
817 assert_eq!(
818 claim_mcp_in(dir.path(), "toolu_c", 1_000 + MCP_CALL_TTL_MS + 1),
819 None
820 );
821 }
822
823 #[test]
824 fn a_seat_shaped_name_needs_the_configured_server() {
825 let keys = BTreeSet::from(["ljos".to_string(), "my__seat".to_string()]);
826 assert!(seat_call_matches("mcp__ljos__ljos_vote", None, &keys));
827 assert!(seat_call_matches("mcp__my__seat__ljos_vote", None, &keys));
828 assert!(seat_call_matches(
829 "mcp__ljos__ljos_vote",
830 Some("ljos"),
831 &keys
832 ));
833 assert!(seat_call_matches("ljos_note", Some("ljos"), &keys));
834 assert!(!seat_call_matches(
835 "mcp__plugin_ljos_ljos__ljos_vote",
836 None,
837 &keys
838 ));
839 assert!(!seat_call_matches("mcp__github__ljos_note", None, &keys));
840 assert!(!seat_call_matches("mcp__Ljos__ljos_vote", None, &keys));
841 assert!(!seat_call_matches(
842 "mcp__ljos__ljos_vote",
843 Some("github"),
844 &keys
845 ));
846 assert!(!seat_call_matches("mcp:ljos:ljos_vote", None, &keys));
847 assert!(!seat_call_matches("ljos_vote", None, &keys));
848 assert!(!seat_call_matches(
849 "mcp__github__add_issue_comment",
850 None,
851 &keys
852 ));
853 assert!(!seat_call_matches("Bash", None, &keys));
854 assert!(!seat_call_matches(
855 "mcp__ljos__ljos_vote",
856 None,
857 &BTreeSet::new()
858 ));
859 }
860}