Skip to main content

ljos_cli/
subagent.rs

1//! The subagent a tool call runs for, so its ballot binds its own session
2//! rather than its parent's. Claude Code hands a hook the subagent's
3//! `agent_id` and the call's `tool_use_id`, but gives the command it runs
4//! no variable that names the agent. The hook leaves the call under the
5//! runtime directory, and the `ljos` the command runs, or the MCP server
6//! the call reaches, takes it back once. A shell call stays behind as
7//! taken until it expires, so the same line run again inside the call
8//! binds the session its first run bound and is refused as cast.
9//!
10//! A shell call is kept with the hook's ancestors, and only a process that
11//! runs under the nearest of them still alive takes it: the harness that
12//! ran the hook runs the command too. The files are not signed. Anything
13//! running as the same user can still write one naming a live ancestor of
14//! a later `ljos`; the check stops a record left by hand, or by a process
15//! in another tree, from naming a session.
16
17use crate::proc_tree::Proc;
18use serde_json::Value;
19use std::collections::BTreeSet;
20use std::path::{Path, PathBuf};
21
22/// How long a shell call the hook saw waits to be taken: the longest a
23/// Claude Code shell command runs.
24const CALL_TTL_MS: u64 = 600_000;
25
26/// How long an MCP hook call waits to be taken by `ljos-mcp`.
27const 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    /// The hook's ancestors, nearest first.
36    #[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
44/// A name safe as one path component. `.` is left out, so it can join a
45/// call to its index.
46pub(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
93/// The extension a call takes once a process took it. It stays until
94/// [`CALL_TTL_MS`], so the same line run again inside the call binds what
95/// the first run bound rather than nothing.
96const TAKEN: &str = "taken";
97
98/// The calls under `dir` still waiting, each with its file. A call past
99/// [`CALL_TTL_MS`] is dropped.
100fn waiting(dir: &Path, now: u64) -> Vec<(PathBuf, Call)> {
101    listed(dir, now, "json", CALL_TTL_MS)
102}
103
104/// The MCP calls under `dir` still waiting, within [`MCP_CALL_TTL_MS`].
105fn waiting_mcp(dir: &Path, now: u64) -> Vec<(PathBuf, Call)> {
106    listed(dir, now, "json", MCP_CALL_TTL_MS)
107}
108
109/// The calls under `dir` already taken, within [`CALL_TTL_MS`].
110fn 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/// `mcp__SERVER__NAME`, split on the last `__` so `SERVER` may contain
144/// `__`. `None` when the name is not that shape.
145#[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/// Whether `tool` is one of the seat's MCP tools, `mcp__SERVER__ljos_NAME`,
157/// and `SERVER` is a key whose command is the seat's own MCP server.
158/// The name shape alone is not enough. An unknown or unreadable config
159/// is not a match.
160#[must_use]
161pub fn is_seat_mcp_tool(tool: &str) -> bool {
162    is_confirmed_seat_call(tool, None, None)
163}
164
165/// `tool` is `ljos_*` on a server key the seat's MCP config names.
166/// `field` is a payload server (`server`, `mcp_server_name`). When both
167/// the name and the field name a server, they have to be the same key.
168pub(crate) fn is_confirmed_seat_call(
169    tool: &str,
170    field: Option<&str>,
171    command: Option<&str>,
172) -> bool {
173    // The Cursor payload names the server binary in `command`. When that
174    // field is present it has to be the seat server. A key in a config
175    // file cannot promote a foreign binary.
176    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
203/// `mcp__SERVER__ljos_NAME`. The server key is not checked: a subagent
204/// vote binds to the agent on this call even when `SERVER` is not one
205/// the seat configured.
206fn 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
219/// An MCP tool call. Shell-only checks, such as a missing working
220/// directory, do not apply to it.
221pub 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
229/// Leave what a hook payload says about its tool call. On `PreToolUse`,
230/// each `ljos` line of a shell call is kept for the process that runs it,
231/// and a subagent's `mcp__SERVER__ljos_NAME` call is kept for the server
232/// whether or not `SERVER` is a configured key. On `PostToolUse` what the
233/// call did not take is dropped. The payload's agent is this hook
234/// process's own. Returns whether the call is a `PreToolUse` on a
235/// confirmed seat server: the server judges that call, so the hook has
236/// nothing more to say.
237pub 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
349/// Whether the line a hook saw runs as `argv`. A word the shell expands
350/// (a variable, a substitution, a glob, a tilde) stands for any one word.
351fn 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
359/// The process that ran the hook for `call` and runs this line too: the
360/// nearest of the hook's ancestors still alive, when it is one of `mine`.
361/// A record that names none is no call.
362fn 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/// The call a hook saw run this process's `ljos` line, as this process
368/// took it.
369#[derive(Debug, Clone, PartialEq, Eq)]
370pub struct Taken {
371    pub session: String,
372    pub agent: String,
373    /// The harness that ran the hook and runs this process.
374    pub harness: Proc,
375}
376
377static THIS_CALL: std::sync::OnceLock<Option<Taken>> = std::sync::OnceLock::new();
378
379/// The call this process runs, taken once per process. `session` is the
380/// one its environment names: that session's calls are read first, then
381/// every session's, since a line whose environment names another session
382/// than its hook saw still runs under that hook's harness.
383fn 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/// The harness of the call this process took, once it has looked for one.
393#[must_use]
394pub fn taken_harness() -> Option<Proc> {
395    THIS_CALL.get()?.as_ref().map(|t| t.harness.clone())
396}
397
398/// The agent of the `ljos` line this process runs, when a hook saw it
399/// under `session`. The newest call whose line is this argv is taken, so
400/// no other process takes it too and a parent's call is never answered
401/// by an older one its subagent left. An empty agent is the parent's.
402#[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/// The session and agent of the `ljos` line this process runs, when this
410/// process names no session of its own: a seat whose runner stamps no id
411/// in the shell. The hook saw the line under its session, so every
412/// session's calls are read, and the newest whose line is this argv is
413/// taken once. An empty agent is the parent's.
414#[must_use]
415pub fn claim_unsessioned_command() -> Option<(String, String)> {
416    this_call(None).map(|t| (t.session, t.agent))
417}
418
419/// Whether this process is the `ljos` command line, rather than the MCP
420/// server or a test.
421#[must_use]
422pub fn runs_as_ljos() -> bool {
423    this_line().is_some()
424}
425
426/// This process's argv after the program, when the program is `ljos`: the
427/// hook keeps only lines that run it, so the MCP server takes none.
428fn 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
461/// The newest waiting call under `dirs` that `fits`, taken. With none, the
462/// newest that fits and was already taken: this line ran before inside
463/// the same call, and binds what it bound then.
464fn 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
477/// The first of `found` this process takes, so no other process takes it
478/// too. Its file is left as taken for [`spent`].
479fn 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/// The session and subagent of a Claude Code MCP call, by the tool use id
488/// its `_meta` carries, when its hook saw a subagent make it. Taken once.
489#[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/// The session of a recent MCP hook call when `_meta` carried no id or
509/// `toolUseId`, taken once when only one conversation's hook call is
510/// waiting in the last [`MCP_CALL_TTL_MS`].
511#[must_use]
512pub fn claim_recent_mcp_session() -> Option<String> {
513    recent_mcp_session(true)
514}
515
516/// [`claim_recent_mcp_session`] without taking the record, for a caller
517/// that only wants to know whether the record names it.
518#[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    /// A call as a hook running in this process's place would leave it.
578    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}