Skip to main content

ai_crew_sync/
proxy.rs

1//! `ai-crew-sync mcp proxy`: a local stdio MCP server that forwards every
2//! tool call to the remote bus as **one** agent in **one** session.
3//!
4//! Why a proxy at all: the bus is stateless Streamable HTTP and any MCP client
5//! can talk to it directly. What a direct connection cannot do is give each
6//! *conversation* its own session when several windows open the same
7//! repository with the same token — a header set once in a client config is
8//! the same header in every window. A stdio server is started by the host
9//! once per conversation (that is the MCP norm, and what Claude Code and
10//! Codex do), so the process itself is the unit of isolation: it mints the
11//! session label, sends it on every forwarded request, and keeps the
12//! project/role metadata for exactly that window.
13//!
14//! The proxy is host-agnostic. What a host provides is used as an
15//! enrichment, never required:
16//!
17//! - **Conversation identity**, in order: `--host-session` / `BUS_HOST_SESSION`
18//!   (any host that can set per-window environment), `CLAUDE_CODE_SESSION_ID`
19//!   (Claude Code exports it to MCP server processes; trusted only once the
20//!   client names itself `claude-code`, because hosts started from a Claude
21//!   Code terminal inherit it),
22//!   `ANTIGRAVITY_CONVERSATION_ID` (where an Antigravity surface exports it
23//!   to MCP servers), the conversation id a request carries in `_meta`
24//!   (`antigravity.google/conversation_id` from Antigravity, `threadId` from
25//!   Codex), and otherwise the process itself
26//!   — a random id that lives as long as this instance. A known conversation
27//!   id derives a **stable** session label, so a resumed conversation
28//!   reconnects to the same session and a forked one gets a new one.
29//! - **Start-of-session context** goes into the `initialize` result's
30//!   `instructions`, which every MCP client hands to the model. No hook
31//!   needed.
32//! - **Presence** is kept by the proxy itself: a heartbeat on connect (with
33//!   repo and branch read from the project directory), a periodic
34//!   keep-alive, `idle` on exit.
35//!
36//! Two local tools, `configure_session` and `session_status`, never reach the
37//! remote server's catalogue. They change metadata (project, role, channel)
38//! and, within the same team, the profile; a team change needs a new
39//! conversation, because switching credentials cannot erase what this
40//! conversation has already seen. Every profile change is verified with
41//! `whoami` before it is committed, in-flight calls to the old context are
42//! cancelled rather than replayed, and claims or locks held by the old
43//! identity are reported, never transferred.
44//!
45//! Stdout carries MCP framing only; everything else goes to stderr.
46
47use std::{
48    path::{Path, PathBuf},
49    sync::{
50        Arc,
51        atomic::{AtomicUsize, Ordering},
52    },
53    time::Duration,
54};
55
56use anyhow::Context as _;
57use rmcp::{
58    ErrorData, ServerHandler, ServiceExt,
59    model::{
60        CallToolRequestParams, CallToolResponse, CallToolResult, ErrorCode, ListToolsResult,
61        PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool,
62    },
63    service::{ClientInitializeError, RequestContext, RoleServer, RunningService, ServiceError},
64    transport::{
65        StreamableHttpClientTransport,
66        streamable_http_client::{StreamableHttpClientTransportConfig, StreamableHttpError},
67    },
68};
69use schemars::JsonSchema;
70use serde::{Deserialize, Serialize};
71use serde_json::{Value, json};
72use tokio::sync::{Mutex, RwLock};
73use tokio_util::sync::CancellationToken;
74
75use crate::context::{self, Inputs, Resolved};
76
77/// Prefix of a session label the proxy mints. Opaque on purpose: the label
78/// is an address, `project`/`role` are the human-facing part.
79pub const SESSION_PREFIX: &str = "s-";
80/// Hex characters after the prefix. 128 bits: the label is an address that
81/// partitions cursors, claims and locks, so two conversations colliding
82/// would merge them silently. The session-label limit is 64 bytes, which
83/// leaves room to spare.
84const SESSION_HEX: usize = 32;
85
86/// Presence lease the proxy keeps alive, and how often it renews it.
87const PRESENCE_TTL_SECS: i64 = 900;
88const KEEPALIVE_EVERY: Duration = Duration::from_secs(300);
89/// Credential lifetime the proxy asks the bus for, in seconds, when
90/// `BUS_SESSION_TTL_SECS` is set; unset takes the bus default. The bus
91/// bounds it (60 s to 24 h).
92const SESSION_TTL_ENV: &str = "BUS_SESSION_TTL_SECS";
93/// How long before its expiry the credential is renewed, in seconds, when
94/// `BUS_SESSION_RENEW_LEAD_SECS` is set; unset renews half-way through the
95/// lifetime, so a transient failure has the other half to retry in.
96const RENEW_LEAD_ENV: &str = "BUS_SESSION_RENEW_LEAD_SECS";
97/// How long a context switch waits for in-flight calls to the old context
98/// after cancelling them, before swapping anyway.
99const DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
100/// How long the exit heartbeat may take; the host is waiting.
101const EXIT_TIMEOUT: Duration = Duration::from_secs(3);
102/// Attempts at opening a connection with a credential `resume_session` just
103/// rotated, before the window gives up and goes off the bus. The old secret
104/// is already refused, so one transient failure must not strand it.
105const ADOPT_ATTEMPTS: u32 = 3;
106/// How long a request waits for the project an MCP root may name, and how
107/// long the client gets to answer `roots/list`.
108const ROOTS_WAIT: Duration = Duration::from_secs(5);
109
110pub const CONFIGURE_TOOL: &str = "configure_session";
111pub const STATUS_TOOL: &str = "session_status";
112/// What the bus serves in one `wait_for_updates` request.
113const SERVER_WAIT_LIMIT_SECS: i64 = 55;
114/// Each slice of a long wait: under the bus's limit with room to spare.
115const SERVER_WAIT_SLICE_SECS: i64 = 50;
116/// The longest wait the proxy covers in one call (30 minutes). A host must
117/// allow a tool call that long (Codex: `tool_timeout_sec`).
118const PROXY_MAX_WAIT_SECS: i64 = 1800;
119
120type Remote = RunningService<rmcp::RoleClient, rmcp::model::ClientConfig>;
121
122/// The `_meta` key under which Antigravity names the conversation on every
123/// `tools/call`.
124pub const ANTIGRAVITY_META_KEY: &str = "antigravity.google/conversation_id";
125
126/// Where the conversation id came from, reported by `session_status`.
127#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)]
128#[serde(rename_all = "kebab-case")]
129pub enum Binding {
130    /// `--host-session` or `BUS_HOST_SESSION`.
131    Explicit,
132    /// `CLAUDE_CODE_SESSION_ID` in the environment, with a client that named
133    /// itself Claude Code.
134    ClaudeCode,
135    /// Google Antigravity's conversation id: `_meta["antigravity.google/
136    /// conversation_id"]` on the first forwarded call, or
137    /// `ANTIGRAVITY_CONVERSATION_ID` in the environment where a surface
138    /// exports it to MCP servers.
139    Antigravity,
140    /// `_meta.threadId` on the first forwarded call.
141    RequestMeta,
142    /// No conversation id: this process is the conversation.
143    Instance,
144}
145
146/// Command-line shape of the proxy.
147#[derive(Clone, Debug, Default)]
148pub struct ProxyOptions {
149    pub inputs: Inputs,
150    pub project: Option<String>,
151    pub role: Option<String>,
152    pub channel: Option<String>,
153    /// Explicit conversation id.
154    pub host_session: Option<String>,
155    /// Where the proxy keeps its binding record; the config directory.
156    pub state_dir: PathBuf,
157}
158
159/// Session label derived from a conversation id: stable for the same id,
160/// unrelated to repository, role or pid. Shared with the resolver so a hook
161/// of the same conversation lands on the same session without coordinating.
162pub use crate::context::session_for_host as session_for;
163
164/// Validate a discovery label the way the bus does, so `configure_session`
165/// refuses what `heartbeat` would reject rather than storing it locally and
166/// reporting a success the server never saw. Empty clears.
167fn check_label(field: &str, raw: &str) -> Result<Option<String>, ErrorData> {
168    crate::store::presence::normalize_label(field, raw)
169        .map(|v| (!v.is_empty()).then_some(v))
170        .map_err(|e| ErrorData::invalid_params(e.to_string(), None))
171}
172
173/// A non-blank environment variable, trimmed.
174fn env_id(name: &str) -> Option<String> {
175    std::env::var(name)
176        .ok()
177        .map(|s| s.trim().to_owned())
178        .filter(|s| !s.is_empty())
179}
180
181/// The local path a `file://` URI names, percent-decoded. Anything else
182/// (another scheme, a remote host) is not a directory this process can read.
183fn path_of_file_uri(uri: &str) -> Option<PathBuf> {
184    let rest = uri.strip_prefix("file://")?;
185    let rest = rest.strip_prefix("localhost").unwrap_or(rest);
186    if !rest.starts_with('/') {
187        return None;
188    }
189    let bytes = rest.as_bytes();
190    let mut out = Vec::with_capacity(bytes.len());
191    let mut i = 0;
192    while i < bytes.len() {
193        if bytes[i] == b'%'
194            && i + 2 < bytes.len()
195            && let Ok(b) = u8::from_str_radix(rest.get(i + 1..i + 3)?, 16)
196        {
197            out.push(b);
198            i += 3;
199        } else {
200            out.push(bytes[i]);
201            i += 1;
202        }
203    }
204    let path = PathBuf::from(String::from_utf8(out).ok()?);
205    path.is_dir().then_some(path)
206}
207
208/// Whether an MCP client name is Claude Code's own (`claude-code`).
209fn is_claude_code(name: &str) -> bool {
210    name.trim().eq_ignore_ascii_case("claude-code")
211}
212
213fn random_session() -> String {
214    let raw = crate::auth::generate_token();
215    format!(
216        "{SESSION_PREFIX}{}",
217        &raw[crate::auth::TOKEN_PREFIX.len()..crate::auth::TOKEN_PREFIX.len() + SESSION_HEX]
218    )
219}
220
221fn env_secs(name: &str) -> Option<i64> {
222    std::env::var(name)
223        .ok()
224        .and_then(|v| v.trim().parse::<i64>().ok())
225        .filter(|v| *v > 0)
226}
227
228/// The lifetime the proxy asks for on register, resume and renew; `None`
229/// leaves it to the bus. Clamped to the bounds the bus applies, so the
230/// renewal schedule is computed from the lifetime actually issued rather
231/// than from a number the bus would have cut down.
232fn requested_session_ttl() -> Option<i64> {
233    env_secs(SESSION_TTL_ENV).map(|v| v.clamp(60, crate::auth::MAX_SESSION_TTL_SECS))
234}
235
236/// Seconds before expiry at which the credential is renewed, for a
237/// credential of `lifetime` seconds: the override when set, else half the
238/// lifetime, and always inside the lifetime so the deadline is never
239/// already past on issue.
240fn renewal_lead_secs(lifetime: i64, override_secs: Option<i64>) -> i64 {
241    let lifetime = lifetime.max(2);
242    override_secs.unwrap_or(lifetime / 2).clamp(1, lifetime - 1)
243}
244
245/// The credential this window proved itself with, when the bus issues them.
246#[derive(Clone, Debug)]
247pub struct SessionProof {
248    /// The secret. Written only to the 0600 binding file, never to a tool
249    /// result, a log or argv.
250    pub token: String,
251    pub session_id: String,
252    pub epoch: i64,
253    pub expires_at: String,
254}
255
256/// What one renewal attempt did.
257enum Renewal {
258    Renewed,
259    /// The bus rejected the credential: revoked, expired or replaced.
260    Refused,
261    /// Transient: try again before the credential lapses.
262    Retry,
263    /// Nothing to renew, or the answer no longer applies to this context.
264    Nothing,
265}
266
267/// The verified, connected context of this instance.
268struct Connected {
269    resolved: Resolved,
270    agent: String,
271    team: String,
272    remote: Arc<Remote>,
273    tools: Vec<Tool>,
274    remote_instructions: Option<String>,
275    proof: Option<SessionProof>,
276    /// Cancelled when this context is replaced; forwarded calls race it.
277    ct: CancellationToken,
278}
279
280struct State {
281    /// Present once a profile resolved and verified. Absent means the proxy
282    /// serves only its local tools and says why in `instructions`.
283    connected: Option<Connected>,
284    /// Why there is no connection, for the model.
285    disconnected_reason: Option<String>,
286    session: String,
287    binding: Binding,
288    host_id: Option<String>,
289    /// True when `host_id` came from a request's `_meta` rather than from
290    /// the environment or a flag: only then is a second id of the same kind
291    /// proof that the host multiplexes conversations over this process.
292    bound_by_request: bool,
293    project: Option<String>,
294    role: Option<String>,
295    channel: Option<String>,
296    /// Bumped on every context switch.
297    generation: u64,
298    /// A credential `resume_session` rotated whose new secret could not open
299    /// a connection. The context it belonged to is retired, and this keeps
300    /// the binding holding the new secret so a restart resumes with it.
301    /// It is still this window's identity: read it through
302    /// [`State::identity`], never through `connected` alone.
303    stranded: Option<Identity>,
304    /// The workspace an MCP root named, when the proxy's own directory had
305    /// no project (#236). Used for `.acs.toml` and for repo/branch in place
306    /// of the working directory, and only ever filled when nothing more
307    /// explicit chose the project.
308    roots_dir: Option<PathBuf>,
309}
310
311/// Who this window is on the bus: the agent, team and credential it speaks
312/// as, whether or not a connection is open right now.
313#[derive(Clone)]
314struct Identity {
315    profile: Option<String>,
316    agent: String,
317    team: String,
318    mcp_url: String,
319    proof: Option<SessionProof>,
320}
321
322impl State {
323    /// This window's identity: the connected context, else the one a
324    /// failed adoption retired. Every check of team, ownership or
325    /// credential reads it here: a window that lost its connection is
326    /// still bound to its team and still owns its binding, and a check
327    /// that looked only at `connected` would treat it as nobody.
328    fn identity(&self) -> Option<Identity> {
329        match (&self.connected, &self.stranded) {
330            (Some(c), _) => Some(Identity {
331                profile: c.resolved.profile.clone(),
332                agent: c.agent.clone(),
333                team: c.team.clone(),
334                mcp_url: c.resolved.mcp_url.clone(),
335                proof: c.proof.clone(),
336            }),
337            (None, Some(s)) => Some(s.clone()),
338            (None, None) => None,
339        }
340    }
341}
342
343/// Counts calls currently forwarded; decremented on drop so a request the
344/// host abandons mid-flight still lets a context switch drain.
345struct InFlight(Arc<AtomicUsize>);
346
347impl InFlight {
348    fn enter(counter: &Arc<AtomicUsize>) -> Self {
349        counter.fetch_add(1, Ordering::SeqCst);
350        Self(counter.clone())
351    }
352}
353
354impl Drop for InFlight {
355    fn drop(&mut self) {
356        self.0.fetch_sub(1, Ordering::SeqCst);
357    }
358}
359
360/// Tools whose call blocks on the bus waiting for teammates: while one is
361/// in flight the model's turn is waiting, not working.
362const WAITING_TOOLS: &[&str] = &["wait_for_updates", "ask_agent"];
363/// A tool call is reported at most this often while the reported state is
364/// unchanged. Short, because the bus may hold a newer observation this
365/// proxy cannot see (a Stop hook's `idle`): a call more than this long after
366/// the last report always goes out.
367const ATTENTION_REPORT_EVERY: Duration = Duration::from_secs(15);
368/// Least time between two attempts to flush a deferred call.
369const FLUSH_RETRY: Duration = Duration::from_secs(2);
370
371/// What this proxy has seen of its model's turn (#221). The host's tool
372/// calls are the only evidence: a call proves a turn is running, a call to a
373/// waiting tool proves it is blocked on the bus. Nothing here can see a turn
374/// end; hooks report that where the host has them, so an observation older
375/// than [`ATTENTION_REPORT_EVERY`] is never sent late as if it were current.
376#[derive(Default)]
377struct Attention {
378    /// Host tool calls in flight.
379    calls: usize,
380    /// Of those, the ones blocked in a bus wait.
381    waits: usize,
382    /// The latest tool call not yet reported.
383    unreported_call: Option<std::time::Instant>,
384    /// What the bus last accepted from this proxy, and when.
385    reported: Option<(&'static str, std::time::Instant)>,
386}
387
388/// One attention report to send. It is committed with [`Attention::sent`]
389/// only once the bus took it, so a failed send is retried.
390#[derive(Clone, Copy, Debug, PartialEq, Eq)]
391struct AttentionReport {
392    state: &'static str,
393    /// The call this report covers, which also stamps `last_tool_at`.
394    call: Option<std::time::Instant>,
395}
396
397impl AttentionReport {
398    fn tool_activity(&self) -> bool {
399        self.call.is_some()
400    }
401
402    /// The heartbeat fields for this report. A call reported late says how
403    /// long ago it happened, so the bus can keep a newer observation (a Stop
404    /// hook's idle) rather than let a late report overwrite it.
405    fn fields(&self) -> Value {
406        let mut v = json!({
407            "refresh_only": true,
408            "attention": self.state,
409            "tool_activity": self.tool_activity(),
410        });
411        if self.state == "attending"
412            && let Some(at) = self.call
413        {
414            v["attention_observed_ms_ago"] = json!(at.elapsed().as_millis() as i64);
415        }
416        v
417    }
418}
419
420impl Attention {
421    /// What to report now, if anything. `refresh` is the keepalive: it
422    /// re-reports a wait still in flight so that observation stays fresh,
423    /// and it never sends a call that has gone stale: once the model has
424    /// been quiet that long, its turn may have ended, and saying
425    /// `attending` would overwrite what a Stop hook observed.
426    fn due(&mut self, refresh: bool) -> Option<AttentionReport> {
427        let now = std::time::Instant::now();
428        let recent = |at: std::time::Instant| now.duration_since(at) < ATTENTION_REPORT_EVERY * 2;
429        if self.unreported_call.is_some_and(|at| !recent(at)) && self.calls == 0 {
430            self.unreported_call = None;
431        }
432        let reported_age = self.reported.map(|(_, at)| now.duration_since(at));
433        let (state, call) = if self.waits > 0 {
434            let changed = self.reported.is_none_or(|(s, _)| s != "waiting");
435            if !(changed || refresh) {
436                return None;
437            }
438            ("waiting", self.unreported_call)
439        } else if let Some(call) = self.unreported_call {
440            let changed = self.reported.is_none_or(|(s, _)| s != "attending");
441            let old = reported_age.is_none_or(|age| age >= ATTENTION_REPORT_EVERY);
442            if !(changed || old || (refresh && self.calls > 0)) {
443                return None;
444            }
445            ("attending", Some(call))
446        } else if refresh && self.calls > 0 {
447            // A long call still running: the turn is too.
448            ("attending", None)
449        } else {
450            return None;
451        };
452        Some(AttentionReport { state, call })
453    }
454
455    /// When a call deferred by the throttle must be flushed, if one is
456    /// waiting: as soon as the throttle allows, so the window does not read
457    /// stale until the next keepalive.
458    fn flush_at(&self) -> Option<std::time::Instant> {
459        // A wait in flight is reported on its own terms (and re-reported by
460        // the keepalive); nothing to flush meanwhile.
461        if self.waits > 0 {
462            return None;
463        }
464        self.unreported_call?;
465        self.reported.map(|(_, at)| at + ATTENTION_REPORT_EVERY)
466    }
467
468    /// The bus accepted `report`.
469    fn sent(&mut self, report: AttentionReport) {
470        self.reported = Some((report.state, std::time::Instant::now()));
471        // Only the call this report covered; a newer one is still pending.
472        if self.unreported_call.is_some() && self.unreported_call == report.call {
473            self.unreported_call = None;
474        }
475    }
476}
477
478/// One host tool call, counted for attention while it runs.
479struct ToolTurn {
480    attention: Arc<std::sync::Mutex<Attention>>,
481    changed: Arc<tokio::sync::Notify>,
482    waits: bool,
483}
484
485impl ToolTurn {
486    fn enter(proxy: &Proxy, tool: &str) -> Self {
487        let waits = WAITING_TOOLS.contains(&tool);
488        if let Ok(mut a) = proxy.attention.lock() {
489            a.calls += 1;
490            a.waits += usize::from(waits);
491            a.unreported_call = Some(std::time::Instant::now());
492        }
493        proxy.attention_changed.notify_one();
494        Self {
495            attention: proxy.attention.clone(),
496            changed: proxy.attention_changed.clone(),
497            waits,
498        }
499    }
500}
501
502impl Drop for ToolTurn {
503    fn drop(&mut self) {
504        if let Ok(mut a) = self.attention.lock() {
505            a.calls = a.calls.saturating_sub(1);
506            if self.waits {
507                a.waits = a.waits.saturating_sub(1);
508                // Back from a wait: the turn is working again.
509                a.unreported_call = Some(std::time::Instant::now());
510            }
511        }
512        // Also the trailing edge of a throttled burst: a call that ended
513        // after the throttle window reports what it was not allowed to.
514        self.changed.notify_one();
515    }
516}
517
518#[derive(Clone)]
519pub struct Proxy {
520    state: Arc<RwLock<State>>,
521    in_flight: Arc<AtomicUsize>,
522    /// The model's turn as this proxy has seen it, and a ping for the
523    /// keepalive to report a change.
524    attention: Arc<std::sync::Mutex<Attention>>,
525    attention_changed: Arc<tokio::sync::Notify>,
526    /// Serialises context switches.
527    switch: Arc<Mutex<()>>,
528    /// Serialises heartbeats, from reading the labels to the bus's answer.
529    /// The bus stores whatever labels arrive last, so a keepalive or
530    /// attention report that read them before a `configure_session` and
531    /// landed after it would put the old role back (#246).
532    presence: Arc<Mutex<()>>,
533    /// Pinged whenever the connected context changes, so the keepalive
534    /// recomputes its renewal deadline at once instead of on its next
535    /// presence tick: a credential established after startup, or after a
536    /// refusal, must not wait five minutes for its first schedule.
537    wake: Arc<tokio::sync::Notify>,
538    opts: Arc<ProxyOptions>,
539    project_dir: PathBuf,
540    /// A `CLAUDE_CODE_SESSION_ID` from the environment that is not yet
541    /// trusted. A host started from a Claude Code terminal inherits the
542    /// parent's id, so the variable alone cannot tell a Claude Code child
543    /// from Codex or Antigravity launched there (#234). Nothing is resumed
544    /// until the client names itself; held across the connect, so a request
545    /// racing the first one waits for the decision instead of bypassing it.
546    pending_claude: Arc<Mutex<Option<String>>>,
547    /// Held while the proxy asks the client for its roots after
548    /// `initialized`, so the first requests wait for the project it finds
549    /// instead of racing it. Bounded by [`ROOTS_WAIT`].
550    roots_gate: Arc<Mutex<()>>,
551    /// The gate, taken while answering `initialize` and handed to the
552    /// discovery `initialized` starts. Taken there because the `initialize`
553    /// answer precedes every later request of the client, whereas the
554    /// `initialized` notification and the first `tools/list` are handled
555    /// concurrently and the request could slip past a gate taken later.
556    roots_hold: Arc<std::sync::Mutex<Option<tokio::sync::OwnedMutexGuard<()>>>>,
557}
558
559// -------------------------------------------------------------- local tools --
560
561#[derive(Debug, Default, Deserialize, JsonSchema)]
562pub struct ConfigureArgs {
563    /// What this window does on the project: implementation, design,
564    /// review, … One lower-case word. Changing it keeps the session and its
565    /// cursors; it only changes how teammates find you.
566    #[serde(default)]
567    pub role: Option<String>,
568    /// Logical project, usually the repository name. Also the channel this
569    /// session posts to by default when one of that name exists.
570    #[serde(default)]
571    pub project: Option<String>,
572    /// Channel to post to by default; overrides the project's.
573    #[serde(default)]
574    pub channel: Option<String>,
575    /// Switch to another locally approved profile. Verified with whoami
576    /// before anything changes; must stay within the same team.
577    #[serde(default)]
578    pub profile: Option<String>,
579}
580
581#[derive(Debug, Serialize, JsonSchema)]
582pub struct Status {
583    /// True when calls are being forwarded to the bus.
584    pub connected: bool,
585    /// Why not, when `connected` is false.
586    pub error: Option<String>,
587    /// Verified with whoami; never asserted.
588    pub agent: Option<String>,
589    pub team: Option<String>,
590    /// Session label every forwarded call carries.
591    pub session: String,
592    /// `agent/session`: what a teammate puts in `to` to reach this window.
593    pub address: Option<String>,
594    pub project: Option<String>,
595    pub role: Option<String>,
596    pub channel: Option<String>,
597    pub profile: Option<String>,
598    /// Where this window's credential came from: the environment, a
599    /// profile flag, the project's .acs.toml, or the user default. Never
600    /// the credential itself.
601    pub credential_from: Option<String>,
602    pub binding: Binding,
603    pub project_root: Option<String>,
604    /// How `project_root` was found: `project-dir` (`--project-dir` /
605    /// `BUS_PROJECT_DIR`), `working-directory` (searched upward from where
606    /// the host started the proxy) or `mcp-roots` (a workspace the client
607    /// listed in `roots/list`). Absent when there is no project root.
608    pub project_root_from: Option<String>,
609    pub bus: Option<String>,
610    /// What this proxy last reported about the model's turn: `attending`
611    /// (it saw a tool call), `waiting` (a bus wait is in flight), or absent
612    /// when it has seen no call yet. The proxy cannot see a turn end; the
613    /// host's hooks report `idle` where they exist, and list_sessions shows
614    /// the combined observation.
615    pub attention: Option<String>,
616    /// Seconds since that report.
617    pub attention_reported_seconds_ago: Option<u64>,
618}
619
620#[derive(Debug, Serialize, JsonSchema)]
621pub struct ConfigureResult {
622    pub status: Status,
623    /// What the previous identity still holds, when the profile changed.
624    /// Nothing is transferred: these expire by their own leases, or the
625    /// previous identity releases them from its own window.
626    pub previous: Option<PreviousIdentity>,
627}
628
629#[derive(Debug, Serialize, JsonSchema)]
630pub struct PreviousIdentity {
631    pub agent: String,
632    pub team: String,
633    pub session: String,
634    pub open_claims: Vec<String>,
635    pub held_locks: Vec<String>,
636}
637
638fn schema_of<T: JsonSchema>() -> Arc<rmcp::model::JsonObject> {
639    let schema = schemars::schema_for!(T);
640    match serde_json::to_value(schema) {
641        Ok(Value::Object(map)) => Arc::new(map),
642        _ => Arc::new(rmcp::model::JsonObject::new()),
643    }
644}
645
646fn local_tools() -> Vec<Tool> {
647    vec![
648        Tool::new(
649            CONFIGURE_TOOL,
650            "Set how THIS window presents itself on the bus: role (implementation, design, \
651             review, …), project and default channel. Metadata only — it never changes who \
652             you are or your session id, so cursors, claims and locks stay yours. `profile` \
653             switches to another locally approved credential of the same team after \
654             verifying it; a different team needs a new conversation. Affects this window \
655             only.",
656            schema_of::<ConfigureArgs>(),
657        )
658        .with_title("Configure this session")
659        .with_output_schema::<ConfigureResult>(),
660        Tool::new(
661            STATUS_TOOL,
662            "Who this window is on the bus (verified agent and team), its session id and \
663             address (`agent/session`, what teammates use to reach exactly this window), \
664             project, role, default channel, and where its credential came from \
665             (`credential_from`). Never returns credentials.",
666            schema_of::<EmptyArgs>(),
667        )
668        .with_title("Session status")
669        .with_output_schema::<Status>(),
670    ]
671}
672
673#[derive(Debug, Default, Deserialize, JsonSchema)]
674pub struct EmptyArgs {}
675
676// --------------------------------------------------------------- connecting --
677
678async fn connect_remote(
679    url: &str,
680    credential: &str,
681    session: &str,
682    epoch: Option<i64>,
683) -> anyhow::Result<Remote> {
684    let mut config = StreamableHttpClientTransportConfig::with_uri(url.to_owned());
685    config.auth_header = Some(credential.to_owned());
686    config.allow_stateless = true;
687    config.custom_headers.insert(
688        crate::auth::SESSION_HEADER.parse()?,
689        session
690            .parse()
691            .context("session label is not a valid header value")?,
692    );
693    // Fencing is opt-in per connection: with it, a request from a process
694    // that has been resumed away is refused instead of writing as the window
695    // that replaced it.
696    if let Some(epoch) = epoch {
697        config.custom_headers.insert(
698            crate::auth::EPOCH_HEADER.parse()?,
699            epoch
700                .to_string()
701                .parse()
702                .context("epoch is not a valid header value")?,
703        );
704    }
705    let transport = StreamableHttpClientTransport::from_config(config);
706    let remote = rmcp::model::ClientConfig::default()
707        .serve(transport)
708        .await
709        .map_err(|e| {
710            // `session_status` and every refusal while disconnected show
711            // this text to the model: the bus's words when it refused, one
712            // sentence when it could not be reached, the URL and the OS
713            // error only in the log.
714            tracing::warn!(error = %e, url, "could not open a connection to the bus");
715            let lost = || "the connection failed before an answer came back".to_owned();
716            let (why, rejected) = match &e {
717                ClientInitializeError::JsonRpcError(data) => (data.message.to_string(), false),
718                ClientInitializeError::TransportError { error, .. } => {
719                    let rejected = matches!(
720                        http_error_in(&*error.error),
721                        Some(StreamableHttpError::AuthRequired(_))
722                    );
723                    let why = match refusal_in(&*error.error) {
724                        Some(r) => r.text(),
725                        None if rejected => "the bus rejected the credential".to_owned(),
726                        None => lost(),
727                    };
728                    (why, rejected)
729                }
730                _ => (lost(), false),
731            };
732            let text = format!("could not connect to the bus: {why}");
733            if rejected {
734                anyhow::Error::new(Verdict::Unauthorized).context(text)
735            } else {
736                anyhow::anyhow!(text)
737            }
738        })?;
739    // The handshake now carries the bus's real version (a 0.7.0-or-older
740    // server identifies as `rmcp`, which says nothing). Skew is reported as
741    // a hint, never as a refusal: nothing here decides that two versions
742    // are incompatible — it only ends the search when something else fails.
743    let peer_info = remote.peer_info();
744    if let Some(si) = peer_info.as_ref().and_then(|i| i.server_info.as_ref()) {
745        let ours = env!("CARGO_PKG_VERSION");
746        if si.name == "ai-crew-sync" && si.version != ours {
747            tracing::warn!(
748                binary = ours,
749                bus = %si.version,
750                "this binary and the bus run different ai-crew-sync versions; \
751                 if tools fail to load or calls are refused, align the two \
752                 before debugging anything else"
753            );
754        } else if si.name != "ai-crew-sync" {
755            tracing::debug!(
756                server = %si.name,
757                version = %si.version,
758                "the bus did not identify an ai-crew-sync version (0.7.0 or older)"
759            );
760        }
761    }
762    Ok(remote)
763}
764
765/// The bus refused this window's bearer. Only the transport can say so: a
766/// 401 never reaches the JSON-RPC layer, and a tool's own error is the bus
767/// talking *to* the model, free to quote a session label, a lease or an id
768/// that happens to spell "401". Matching that text once turned an ordinary
769/// "held by joaquin" refusal into "your credential was revoked".
770fn unauthorized(e: &ServiceError) -> bool {
771    let ServiceError::TransportSend(sent) = e else {
772        return false;
773    };
774    match http_error_in(&*sent.error) {
775        Some(http) => matches!(http, StreamableHttpError::AuthRequired(_)),
776        // A transport error that is not the reqwest one: fall back to the
777        // wording rmcp gives a rejected bearer, still never a tool's text.
778        None => sent.error.to_string().contains("Auth required"),
779    }
780}
781
782/// The HTTP transport's own error inside a transport failure, found by
783/// type along the source chain. Every classification of a failed request
784/// (rejected bearer, HTTP refusal) reads it, never the rendered text.
785fn http_error_in<'a>(
786    root: &'a (dyn std::error::Error + 'static),
787) -> Option<&'a StreamableHttpError<reqwest::Error>> {
788    let mut cause = Some(root);
789    while let Some(err) = cause {
790        if let Some(http) = err.downcast_ref::<StreamableHttpError<reqwest::Error>>() {
791            return Some(http);
792        }
793        cause = err.source();
794    }
795    None
796}
797
798/// The bus has no such tool. rmcp answers an unknown tool with
799/// `invalid_params("tool not found")`, and a JSON-RPC layer without the
800/// method with -32601. A refusal from a tool that exists is neither,
801/// whatever it quotes: the bus's own "not found: …" errors share the code,
802/// and a live-label conflict quotes the label, which a conversation id can
803/// hash to `s-32601…`. Matching that text once kept a window label-only,
804/// forwarding with the parent token, exactly when it had to fail closed.
805fn no_such_tool(e: &ServiceError) -> bool {
806    match e {
807        ServiceError::McpError(err) => {
808            err.code == ErrorCode::METHOD_NOT_FOUND
809                || (err.code == ErrorCode::INVALID_PARAMS && err.message.trim() == "tool not found")
810        }
811        _ => false,
812    }
813}
814
815/// What a failed call means, decided from the error's shape, never from its
816/// wording. Carried inside the `anyhow` chain by [`call_remote`] so a caller
817/// that only sees `anyhow::Error` reads the same fact.
818#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
819enum Verdict {
820    #[error("the bus rejected the credential")]
821    Unauthorized,
822    #[error("the bus has no such tool")]
823    NoSuchTool,
824}
825
826fn verdict(e: &ServiceError) -> Option<Verdict> {
827    if unauthorized(e) {
828        Some(Verdict::Unauthorized)
829    } else if no_such_tool(e) {
830        Some(Verdict::NoSuchTool)
831    } else {
832        None
833    }
834}
835
836/// Whether the bus refused a stored session credential because it expired,
837/// as opposed to being revoked (401 "invalid or revoked") or replaced by a
838/// newer process (409, stale epoch). The transport carries the bus's own
839/// refusal text, which is the only place the distinction is visible.
840fn credential_expired(e: &anyhow::Error) -> bool {
841    format!("{e:#}").contains("session credential has expired")
842}
843
844fn verdict_of(e: &anyhow::Error) -> Option<Verdict> {
845    e.chain().find_map(|c| c.downcast_ref::<Verdict>().copied())
846}
847
848/// The delivery ids a confirmation settled: the ones the bus committed
849/// now plus the ones it says were already confirmed for this caller. A bus
850/// too old to list ids answers with a count alone; then the count settles
851/// everything only when it matches what was sent, as before.
852fn settled_ids(reply: Option<&Value>, sent: &[String]) -> std::collections::HashSet<String> {
853    let mut settled = std::collections::HashSet::new();
854    let Some(reply) = reply else {
855        return settled;
856    };
857    let listed = reply.get("confirmed_ids").is_some() || reply.get("already_confirmed").is_some();
858    if listed {
859        for key in ["confirmed_ids", "already_confirmed"] {
860            if let Some(ids) = reply.get(key).and_then(|v| v.as_array()) {
861                settled.extend(ids.iter().filter_map(|v| v.as_str()).map(str::to_owned));
862            }
863        }
864    } else if reply.get("confirmed").and_then(|v| v.as_i64()) == Some(sent.len() as i64) {
865        settled.extend(sent.iter().cloned());
866    }
867    settled
868}
869
870/// The bus answered over HTTP and refused the request before running it.
871///
872/// The bus's middleware (the body limit, the rate limit, a stale epoch, an
873/// expired session credential) answers with a status and `{"error": "…"}`
874/// written for the model. That body is not a JSON-RPC error, so rmcp does
875/// not hand it back as one: it arrives as
876/// `UnexpectedServerResponse("HTTP {status}: {body}")` inside the transport
877/// error, where it looks like a lost connection unless read for its shape.
878#[derive(Debug, PartialEq, Eq)]
879struct Refusal {
880    status: u16,
881    /// The bus's own words, when the body was the bus's `{"error": …}`.
882    said: Option<String>,
883}
884
885impl Refusal {
886    fn text(&self) -> String {
887        match &self.said {
888            Some(said) => format!("the bus refused it before running it: {said}"),
889            None => format!(
890                "the bus refused it with HTTP {} before running it",
891                self.status
892            ),
893        }
894    }
895}
896
897/// The refusal inside a transport error, if the bus answered with one.
898/// Only an answer that certainly ran nothing counts: the bus's own body at
899/// any status, or a bare 4xx. A bare 5xx may come from a gateway that timed
900/// out waiting for a call that did run, so it stays a lost connection.
901fn refusal_in(root: &(dyn std::error::Error + 'static)) -> Option<Refusal> {
902    let StreamableHttpError::UnexpectedServerResponse(msg) = http_error_in(root)? else {
903        return None;
904    };
905    let rest = msg.strip_prefix("HTTP ")?;
906    let (head, body) = rest.split_once(": ").unwrap_or((rest, ""));
907    let status: u16 = head.split_whitespace().next()?.parse().ok()?;
908    let said = serde_json::from_str::<Value>(body)
909        .ok()
910        .and_then(|v| v.get("error")?.as_str().map(str::to_owned));
911    (said.is_some() || (400..500).contains(&status)).then_some(Refusal { status, said })
912}
913
914fn refusal(e: &ServiceError) -> Option<Refusal> {
915    match e {
916        ServiceError::TransportSend(sent) => refusal_in(&*sent.error),
917        _ => None,
918    }
919}
920
921/// What a failed call to the bus means, in words the model can use.
922///
923/// The bus writes its errors for the model, so an MCP error from it is its
924/// own message — never `ServiceError`'s rendering of it (`"Mcp error:
925/// -32602: …"`), which names a JSON-RPC code nobody downstream can act on.
926/// An HTTP refusal is the bus's too, in its middleware's words. Anything
927/// else is this proxy losing the bus: said once, without the transport's
928/// internals (URLs, OS errors), which go to the log.
929fn remote_error_text(e: &ServiceError) -> String {
930    if let ServiceError::McpError(data) = e {
931        return data.message.to_string();
932    }
933    tracing::warn!(error = %e, "the call to the bus failed in transport");
934    match refusal(e) {
935        Some(r) => r.text(),
936        None => "the connection to the bus failed before an answer came back".to_owned(),
937    }
938}
939
940async fn call_remote(remote: &Remote, name: &str, args: Value) -> anyhow::Result<Value> {
941    let arguments: rmcp::model::JsonObject =
942        serde_json::from_value(args).context("arguments must be an object")?;
943    let result = remote
944        .call_tool(CallToolRequestParams::new(name.to_owned()).with_arguments(arguments))
945        .await
946        .map_err(|e| {
947            // The verdict is read from the error's shape and carried in the
948            // chain; the text is only what a reader of the chain will see.
949            let text = format!("{name}: {}", remote_error_text(&e));
950            match verdict(&e) {
951                Some(v) => anyhow::Error::new(v).context(text),
952                None => anyhow::anyhow!(text),
953            }
954        })?;
955    if result.is_error == Some(true) {
956        // The tool's own words, not the Rust `Debug` of its content blocks.
957        let said: Vec<String> = result
958            .content
959            .iter()
960            .filter_map(|c| c.as_text().map(|t| t.text.clone()))
961            .collect();
962        anyhow::bail!("{name}: {}", said.join(" "));
963    }
964    Ok(result.structured_content.unwrap_or(Value::Null))
965}
966
967/// Resolve, connect with the session header, and verify who the token is.
968/// Register this window for the first time. A server too old to know the
969/// tool keeps the label-only connection it has always served; any other
970/// failure is fatal, because continuing would forward with the parent token
971/// under an asserted label while reporting a proven identity.
972async fn register_new(remote: &Remote, session: &str) -> anyhow::Result<Option<SessionProof>> {
973    let mut args = json!({ "session": session });
974    if let Some(ttl) = requested_session_ttl() {
975        args["ttl_seconds"] = json!(ttl);
976    }
977    match call_remote(remote, "register_session", args).await {
978        Ok(v) => match v["session_token"].as_str() {
979            Some(token) => Ok(Some(SessionProof {
980                token: token.to_owned(),
981                session_id: v["session_id"].as_str().unwrap_or_default().to_owned(),
982                epoch: v["epoch"].as_i64().unwrap_or(1),
983                expires_at: v["expires_at"].as_str().unwrap_or_default().to_owned(),
984            })),
985            None => anyhow::bail!(
986                "the bus accepted register_session but returned no credential; refusing to \
987                 continue with an asserted label while reporting a proven identity"
988            ),
989        },
990        Err(e) => {
991            let text = e.to_string();
992            // Only "this server has no such tool" is a legacy bus. A refusal,
993            // a database error or a dropped connection is not, and failing
994            // closed is the point.
995            if verdict_of(&e) == Some(Verdict::NoSuchTool) {
996                tracing::warn!(
997                    "this bus does not issue session credentials; continuing with the label only"
998                );
999                Ok(None)
1000            } else {
1001                Err(anyhow::anyhow!(
1002                    "could not register this window's session: {text}"
1003                ))
1004            }
1005        }
1006    }
1007}
1008
1009/// Reconnect a window we already hold a credential for. Its own credential
1010/// is the proof, so no agent token is involved; a rejected resume means the
1011/// window was revoked, rotated or expired and the caller must register
1012/// afresh.
1013async fn resume_with(
1014    url: &str,
1015    prior: &SessionProof,
1016    session: &str,
1017) -> anyhow::Result<Option<SessionProof>> {
1018    let remote = connect_remote(url, &prior.token, session, Some(prior.epoch))
1019        .await
1020        .context("the stored session credential could not open a connection")?;
1021    let mut args = json!({});
1022    if let Some(ttl) = requested_session_ttl() {
1023        args["ttl_seconds"] = json!(ttl);
1024    }
1025    let outcome = call_remote(&remote, "resume_session", args).await;
1026    let _ = remote.cancel().await;
1027    let v =
1028        outcome.context("this window's session could not be resumed; it may have been revoked")?;
1029    let token = v["session_token"]
1030        .as_str()
1031        .context("resume_session returned no credential")?;
1032    Ok(Some(SessionProof {
1033        token: token.to_owned(),
1034        session_id: v["session_id"].as_str().unwrap_or_default().to_owned(),
1035        epoch: v["epoch"].as_i64().unwrap_or(1),
1036        expires_at: v["expires_at"].as_str().unwrap_or_default().to_owned(),
1037    }))
1038}
1039
1040/// The rotation `resume_session` asked for through the proxy, with the
1041/// credential the window holds. The error is the one sentence the model
1042/// reads, classified once like a forwarded call's: whether the resume
1043/// happened, and what to do. The transport's detail goes to the log only.
1044async fn rotate(url: &str, prior: &SessionProof, session: &str) -> Result<SessionProof, String> {
1045    const KEPT: &str = "nothing was resumed and this window keeps the credential it holds";
1046    let rejected = || {
1047        "resume_session: the bus rejected this window's credential (revoked or rotated), so \
1048         nothing was resumed; restart this MCP server to reconnect the window"
1049            .to_owned()
1050    };
1051    let remote = connect_remote(url, &prior.token, session, Some(prior.epoch))
1052        .await
1053        .map_err(|e| {
1054            tracing::warn!(error = format!("{e:#}"), "resume_session could not connect");
1055            if verdict_of(&e) == Some(Verdict::Unauthorized) {
1056                rejected()
1057            } else {
1058                // `connect_remote` words its own error for the model.
1059                format!("resume_session: {e}; {KEPT}. Try again once the bus answers")
1060            }
1061        })?;
1062    let mut args = rmcp::model::JsonObject::new();
1063    if let Some(ttl) = requested_session_ttl() {
1064        args.insert("ttl_seconds".into(), json!(ttl));
1065    }
1066    let outcome = remote
1067        .call_tool(CallToolRequestParams::new("resume_session").with_arguments(args))
1068        .await;
1069    let _ = remote.cancel().await;
1070    let lost = |what: &str| {
1071        format!(
1072            "resume_session: {what}, so the resume may or may not have happened. If this \
1073             window's next call is refused, it did and the new credential is lost: close the \
1074             window with revoke_session (its label, from another window of this agent or \
1075             `ai-crew-sync client call revoke_session`) and restart this MCP server"
1076        )
1077    };
1078    let result = outcome.map_err(|e| {
1079        tracing::warn!(error = %e, "resume_session through the proxy failed");
1080        if unauthorized(&e) {
1081            rejected()
1082        } else if let ServiceError::McpError(data) = &e {
1083            format!("resume_session: {KEPT}: {}", data.message)
1084        } else if let Some(r) = refusal(&e) {
1085            format!("resume_session: {}; {KEPT}", r.text())
1086        } else {
1087            lost("the connection to the bus failed before an answer came back")
1088        }
1089    })?;
1090    if result.is_error == Some(true) {
1091        let said: Vec<String> = result
1092            .content
1093            .iter()
1094            .filter_map(|c| c.as_text().map(|t| t.text.clone()))
1095            .collect();
1096        return Err(format!("resume_session: {KEPT}: {}", said.join(" ")));
1097    }
1098    let v = result.structured_content.unwrap_or(Value::Null);
1099    let Some(token) = v["session_token"].as_str() else {
1100        tracing::warn!("resume_session answered without a credential");
1101        return Err(lost("the bus answered without a credential"));
1102    };
1103    Ok(SessionProof {
1104        token: token.to_owned(),
1105        session_id: v["session_id"].as_str().unwrap_or_default().to_owned(),
1106        epoch: v["epoch"].as_i64().unwrap_or(1),
1107        expires_at: v["expires_at"].as_str().unwrap_or_default().to_owned(),
1108    })
1109}
1110
1111/// A connection on `proof` with the bus's tool list, or why not in words
1112/// the model can read.
1113async fn open_with(
1114    url: &str,
1115    proof: &SessionProof,
1116    session: &str,
1117) -> Result<(Remote, Vec<Tool>), String> {
1118    let remote = connect_remote(url, &proof.token, session, Some(proof.epoch))
1119        .await
1120        // `connect_remote` logged the transport's detail and words its own
1121        // error for the model.
1122        .map_err(|e| e.to_string())?;
1123    match remote.list_all_tools().await {
1124        Ok(tools) => Ok((remote, tools)),
1125        Err(e) => {
1126            let why = format!("could not list the bus's tools: {}", remote_error_text(&e));
1127            let _ = remote.cancel().await;
1128            Err(why)
1129        }
1130    }
1131}
1132
1133async fn establish(
1134    inputs: &Inputs,
1135    session: &str,
1136    existing_proof: Option<SessionProof>,
1137) -> anyhow::Result<(
1138    Resolved,
1139    String,
1140    String,
1141    Remote,
1142    Vec<Tool>,
1143    Option<String>,
1144    Option<SessionProof>,
1145)> {
1146    let resolved = context::resolve(inputs)?;
1147    // Shadow warnings surface in the log (stderr): the host shows them with
1148    // the server's output, and MCP stdout stays protocol-clean.
1149    for w in &resolved.warnings {
1150        tracing::warn!("{w}");
1151    }
1152    // Nothing outranked anything, but the identity was not chosen for this
1153    // directory either: say so where the host shows the server's output.
1154    if resolved.source == context::Source::UserDefault {
1155        tracing::warn!(
1156            "no BUS_TOKEN, no BUS_PROFILE and no .acs.toml naming a profile here: this window \
1157             uses the user default profile '{}'. `ai-crew-sync context profile default --clear` removes it; a \
1158             project's .acs.toml selects its own",
1159            resolved.profile.as_deref().unwrap_or("?")
1160        );
1161    }
1162    // First connection: the agent token, with the label in a header, exactly
1163    // as any direct client would.
1164    // The same wording a forwarded 401 gets, so a window started with a
1165    // rotated token says what to do rather than "the bus did not accept the
1166    // credential". The bus refuses it at connect or at whoami. Provenance
1167    // names where the failing credential came from without revealing it.
1168    let rejected = || {
1169        anyhow::anyhow!(
1170            "the bus rejected this window's credential — it has been revoked or \
1171             rotated{}. The credential came from {}. Issue a new token \
1172             (`ai-crew-sync admin token issue --save`) or select another \
1173             approved profile",
1174            resolved
1175                .profile
1176                .as_deref()
1177                .map(|p| format!(" (profile '{p}')"))
1178                .unwrap_or_default(),
1179            resolved.credential_provenance()
1180        )
1181    };
1182    let remote = match connect_remote(&resolved.mcp_url, &resolved.token, session, None).await {
1183        Ok(remote) => remote,
1184        Err(e) if verdict_of(&e) == Some(Verdict::Unauthorized) => return Err(rejected()),
1185        Err(e) => return Err(e),
1186    };
1187    let me = match call_remote(&remote, "whoami", json!({})).await {
1188        Ok(me) => me,
1189        Err(e) => {
1190            let raw = e.to_string();
1191            let _ = remote.cancel().await;
1192            if verdict_of(&e) == Some(Verdict::Unauthorized) {
1193                return Err(rejected());
1194            }
1195            anyhow::bail!("the bus did not accept the credential: {raw}");
1196        }
1197    };
1198    let agent = me["agent"].as_str().unwrap_or_default().to_owned();
1199    let team = me["team"].as_str().unwrap_or_default().to_owned();
1200    if let Some((exp_team, exp_agent)) = &resolved.expected
1201        && (&agent != exp_agent || &team != exp_team)
1202    {
1203        let _ = remote.cancel().await;
1204        anyhow::bail!(
1205            "profile '{}' expects {exp_agent}@{exp_team} but the token authenticates as \
1206             {agent}@{team}; fix the profile or its token entry",
1207            resolved.profile.as_deref().unwrap_or("?")
1208        );
1209    }
1210
1211    // Then upgrade: talk with a credential that *proves* which window this
1212    // is. Reconnecting an existing window resumes it with the credential
1213    // already on disk — the bus refuses to hand a live window to whoever
1214    // holds the agent token — and a window with no stored credential
1215    // registers a new one.
1216    let stored = existing_proof;
1217    let proof = match &stored {
1218        Some(prior) => match resume_with(&resolved.mcp_url, prior, session).await {
1219            Ok(proof) => proof,
1220            // Expired: the window was away longer than its credential lives
1221            // (closed, or the machine asleep). The bus says to register
1222            // again with the agent token, and the label — with everything
1223            // filed under it — is unchanged.
1224            Err(e) if credential_expired(&e) => {
1225                tracing::warn!(
1226                    "this window's stored session credential had expired; registering \
1227                     session '{session}' again with the agent token"
1228                );
1229                register_new(&remote, session).await?
1230            }
1231            // Revoked or rotated: the bus no longer knows this secret (a
1232            // `revoke_session`, a revoked parent token, a rotation this
1233            // file never saw, or a bus that lost its sessions). Retrying
1234            // the same secret can only fail again, so the agent token —
1235            // already verified above — asks for the label. The bus decides:
1236            // a label that is revoked or dead is registered again, as
1237            // `register_session` documents, and one that is still live (the
1238            // process that rotated it holds the new secret) is refused, so
1239            // nothing is taken back from a window that can still speak for
1240            // itself.
1241            Err(e) if verdict_of(&e) == Some(Verdict::Unauthorized) => {
1242                tracing::warn!(
1243                    "the bus rejected this window's stored session credential (revoked or \
1244                     rotated); registering session '{session}' again with the agent token"
1245                );
1246                register_new(&remote, session).await?
1247            }
1248            // A stale epoch (409): another process resumed this window
1249            // after the stored credential was written. Fail closed.
1250            Err(e) => return Err(e),
1251        },
1252        None => register_new(&remote, session).await?,
1253    };
1254
1255    let (remote, tools, instructions) = match &proof {
1256        Some(proof) => {
1257            let _ = remote.cancel().await;
1258            let remote =
1259                connect_remote(&resolved.mcp_url, &proof.token, session, Some(proof.epoch))
1260                    .await
1261                    .context("the session credential could not open a connection")?;
1262            let tools = remote.list_all_tools().await.map_err(|e| {
1263                anyhow::anyhow!("could not list the bus's tools: {}", remote_error_text(&e))
1264            })?;
1265            let instructions = remote.peer_info().and_then(|i| i.instructions.clone());
1266            (remote, tools, instructions)
1267        }
1268        None => {
1269            let tools = remote.list_all_tools().await.map_err(|e| {
1270                anyhow::anyhow!("could not list the bus's tools: {}", remote_error_text(&e))
1271            })?;
1272            let instructions = remote.peer_info().and_then(|i| i.instructions.clone());
1273            (remote, tools, instructions)
1274        }
1275    };
1276    Ok((resolved, agent, team, remote, tools, instructions, proof))
1277}
1278
1279/// Repository and branch of the project directory, for presence. Best
1280/// effort: a directory that is not a checkout simply reports neither.
1281fn git_place(dir: &Path) -> (Option<String>, Option<String>) {
1282    let run = |args: &[&str]| {
1283        std::process::Command::new("git")
1284            .args(args)
1285            .current_dir(dir)
1286            .output()
1287            .ok()
1288            .filter(|o| o.status.success())
1289            .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned())
1290            .filter(|s| !s.is_empty())
1291    };
1292    let repo = run(&["config", "--get", "remote.origin.url"]).map(|url| {
1293        let trimmed = url.trim_end_matches(".git");
1294        let tail: Vec<&str> = trimmed.rsplit(['/', ':']).take(2).collect();
1295        if tail.len() == 2 {
1296            format!("{}/{}", tail[1], tail[0])
1297        } else {
1298            trimmed.to_owned()
1299        }
1300    });
1301    let branch = run(&["branch", "--show-current"]);
1302    (repo, branch)
1303}
1304
1305impl Proxy {
1306    /// Resolve the conversation id, connect and verify. Never fails: a proxy
1307    /// that cannot connect still serves its local tools and explains itself.
1308    pub async fn start(opts: ProxyOptions) -> Self {
1309        let project_dir = opts
1310            .inputs
1311            .project_dir
1312            .clone()
1313            .or_else(|| std::env::var_os("CLAUDE_PROJECT_DIR").map(PathBuf::from))
1314            .or_else(|| std::env::current_dir().ok())
1315            .unwrap_or_else(|| PathBuf::from("."));
1316
1317        let explicit = opts
1318            .host_session
1319            .as_deref()
1320            .map(str::trim)
1321            .filter(|s| !s.is_empty())
1322            .map(str::to_owned);
1323        let claude = env_id("CLAUDE_CODE_SESSION_ID");
1324        let (host_id, binding) = match (&explicit, &claude) {
1325            (Some(id), _) => (Some(id.clone()), Binding::Explicit),
1326            // Deferred: see `pending_claude`. Until then the window is not
1327            // bound and not connected.
1328            (None, Some(_)) => (None, Binding::Instance),
1329            (None, None) => match env_id("ANTIGRAVITY_CONVERSATION_ID") {
1330                Some(id) => (Some(id), Binding::Antigravity),
1331                None => (None, Binding::Instance),
1332            },
1333        };
1334        let pending = explicit.is_none().then_some(claude).flatten();
1335        let session = match &host_id {
1336            Some(id) => session_for(id),
1337            None => random_session(),
1338        };
1339        let deferred = pending.is_some();
1340
1341        let proxy = Self {
1342            state: Arc::new(RwLock::new(State {
1343                connected: None,
1344                disconnected_reason: deferred.then(|| {
1345                    "waiting for the MCP client to identify itself before binding the \
1346                     conversation"
1347                        .to_owned()
1348                }),
1349                session,
1350                binding,
1351                host_id,
1352                bound_by_request: false,
1353                project: opts.project.clone(),
1354                role: opts.role.clone(),
1355                channel: opts.channel.clone(),
1356                generation: 0,
1357                stranded: None,
1358                roots_dir: None,
1359            })),
1360            in_flight: Arc::new(AtomicUsize::new(0)),
1361            attention: Arc::new(std::sync::Mutex::new(Attention::default())),
1362            attention_changed: Arc::new(tokio::sync::Notify::new()),
1363            switch: Arc::new(Mutex::new(())),
1364            presence: Arc::new(Mutex::new(())),
1365            wake: Arc::new(tokio::sync::Notify::new()),
1366            opts: Arc::new(opts),
1367            project_dir,
1368            pending_claude: Arc::new(Mutex::new(pending)),
1369            roots_gate: Arc::new(Mutex::new(())),
1370            roots_hold: Arc::new(std::sync::Mutex::new(None)),
1371        };
1372        if !deferred {
1373            proxy.connect_initial().await;
1374        }
1375        proxy
1376    }
1377
1378    /// The first connection of this window. Never fails: a proxy that cannot
1379    /// connect still serves its local tools and explains itself.
1380    async fn connect_initial(&self) {
1381        let inputs = self.inputs(&*self.state.read().await);
1382        if let Err(e) = self.connect_with(&inputs, true).await {
1383            tracing::warn!(error = %e, "proxy started without a bus connection");
1384            self.state.write().await.disconnected_reason = Some(format!("{e:#}"));
1385        }
1386    }
1387
1388    /// Decide a deferred Claude Code binding once the client has named
1389    /// itself, then connect. Only Claude Code's own client gets the
1390    /// conversation the variable names; any other host started under it is
1391    /// a conversation of its own, so the parent's session, epoch and binding
1392    /// record are never touched (#234). A no-op after the first call.
1393    async fn admit(&self, client: Option<&rmcp::model::Implementation>) {
1394        let mut pending = self.pending_claude.lock().await;
1395        let Some(id) = pending.take() else { return };
1396        let name = client.map(|c| c.name.as_str()).unwrap_or_default();
1397        {
1398            let mut st = self.state.write().await;
1399            if is_claude_code(name) {
1400                st.session = session_for(&id);
1401                st.host_id = Some(id);
1402                st.binding = Binding::ClaudeCode;
1403            } else {
1404                tracing::info!(
1405                    client = name,
1406                    "CLAUDE_CODE_SESSION_ID is set but the client is not Claude Code; it was \
1407                     inherited from a parent window and is ignored"
1408                );
1409                if let Some(ag) = env_id("ANTIGRAVITY_CONVERSATION_ID") {
1410                    st.session = session_for(&ag);
1411                    st.host_id = Some(ag);
1412                    st.binding = Binding::Antigravity;
1413                }
1414            }
1415            st.disconnected_reason = None;
1416        }
1417        self.connect_initial().await;
1418    }
1419
1420    /// Wait, bounded, for a `roots/list` discovery in flight.
1421    async fn await_roots(&self) {
1422        let _ = tokio::time::timeout(ROOTS_WAIT * 2, self.roots_gate.lock()).await;
1423    }
1424
1425    /// What the resolver is given: the command line's inputs, with the
1426    /// project directory an MCP root named when there is one.
1427    fn inputs(&self, st: &State) -> Inputs {
1428        let mut inputs = self.opts.inputs.clone();
1429        if let Some(dir) = &st.roots_dir {
1430            inputs.project_dir = Some(dir.clone());
1431        }
1432        inputs
1433    }
1434
1435    /// Find the project through the client's MCP roots when the proxy's own
1436    /// directory has none (#236): a globally installed host plugin starts
1437    /// the proxy in the plugin's directory, far from the workspace. Never
1438    /// overrides `--project-dir`, a profile chosen by flag or environment,
1439    /// or a `.acs.toml` the working directory already found.
1440    // Roots are deprecated by SEP-2577, but Antigravity still advertises
1441    // and answers them, and it is the host this exists for.
1442    #[allow(deprecated)]
1443    async fn discover_roots(&self, peer: &rmcp::Peer<RoleServer>) {
1444        if self.opts.inputs.project_dir.is_some() || self.opts.inputs.profile.is_some() {
1445            return;
1446        }
1447        let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1448        if matches!(context::find_project(&start), Ok(Some(_))) {
1449            return;
1450        }
1451        let roots = match tokio::time::timeout(ROOTS_WAIT, peer.list_roots()).await {
1452            Ok(Ok(r)) => r.roots,
1453            Ok(Err(e)) => {
1454                tracing::debug!(error = %e, "the client did not answer roots/list");
1455                return;
1456            }
1457            Err(_) => {
1458                tracing::debug!("the client did not answer roots/list in time");
1459                return;
1460            }
1461        };
1462        let Some(dir) = roots
1463            .iter()
1464            .filter_map(|r| path_of_file_uri(&r.uri))
1465            .find(|p| matches!(context::find_project(p), Ok(Some(_))))
1466        else {
1467            return;
1468        };
1469        tracing::info!(root = %dir.display(), "project found through the client's MCP roots");
1470        if let Err(e) = self.adopt_project_dir(dir).await {
1471            tracing::warn!(error = %e, "the project an MCP root names could not be used");
1472            return;
1473        }
1474        // The catalogue may have gone from local tools only to the bus's.
1475        let _ = peer.notify_tool_list_changed().await;
1476    }
1477
1478    /// Use `dir` as the project directory. A window already connected as the
1479    /// profile the project names only picks up its labels; otherwise it
1480    /// connects (or switches, within the team) through the usual path.
1481    async fn adopt_project_dir(&self, dir: PathBuf) -> anyhow::Result<()> {
1482        let (inputs, same_profile, connected) = {
1483            let mut st = self.state.write().await;
1484            let previous = st.roots_dir.replace(dir);
1485            let inputs = self.inputs(&st);
1486            let resolved = match context::resolve(&inputs) {
1487                Ok(r) => r,
1488                Err(e) => {
1489                    st.roots_dir = previous;
1490                    return Err(e);
1491                }
1492            };
1493            // The same credential, not just the same profile: the project's
1494            // `key` or name can pick another token entry of that profile.
1495            let same = st.connected.as_ref().is_some_and(|c| {
1496                c.resolved.profile == resolved.profile
1497                    && c.resolved.token == resolved.token
1498                    && c.resolved.mcp_url == resolved.mcp_url
1499            });
1500            if same {
1501                if st.project.is_none() {
1502                    st.project = resolved.project.clone();
1503                }
1504                if st.channel.is_none() {
1505                    st.channel = resolved.channel.clone();
1506                }
1507                if let Some(c) = st.connected.as_mut() {
1508                    c.resolved.project_root = resolved.project_root.clone();
1509                }
1510            }
1511            (inputs, same, st.connected.is_some())
1512        };
1513        if !same_profile {
1514            // Connected under another profile (the user default) means a
1515            // switch: that credential belongs to the identity left behind.
1516            if let Err(e) = self.connect_with(&inputs, !connected).await {
1517                self.state.write().await.roots_dir = None;
1518                return Err(e);
1519            }
1520            return Ok(());
1521        }
1522        self.heartbeat("active").await;
1523        self.write_binding().await;
1524        Ok(())
1525    }
1526
1527    /// Connect a context and make it current. Verifies before it touches
1528    /// state; on failure the previous context, if any, stays.
1529    /// `reuse_proof` is true when this is the *same* window reconnecting, so
1530    /// the credential on disk is its own and a resume is right. A profile
1531    /// switch passes false: that credential belongs to the identity being
1532    /// left behind, and resuming it would keep speaking as them.
1533    async fn connect_with(
1534        &self,
1535        inputs: &Inputs,
1536        reuse_proof: bool,
1537    ) -> anyhow::Result<Option<PreviousIdentity>> {
1538        let _guard = self.switch.lock().await;
1539        let (session, binding_key) = {
1540            let st = self.state.read().await;
1541            (
1542                st.session.clone(),
1543                st.host_id.clone().unwrap_or_else(|| st.session.clone()),
1544            )
1545        };
1546        // A credential already on disk for this conversation means this is a
1547        // reconnect: resume that window with its own proof rather than
1548        // asking the bus to hand it over. Only when the identity is
1549        // unchanged — see `reuse_proof`.
1550        let existing = reuse_proof
1551            .then(|| context::read_binding(&self.opts.state_dir, &binding_key))
1552            .flatten()
1553            .and_then(|b| match (b.session_token, b.session_id, b.epoch) {
1554                (Some(token), Some(session_id), Some(epoch)) if !token.is_empty() => {
1555                    Some(SessionProof {
1556                        token,
1557                        session_id,
1558                        epoch,
1559                        expires_at: b.expires_at.unwrap_or_default(),
1560                    })
1561                }
1562                _ => None,
1563            });
1564        let (resolved, agent, team, remote, tools, instructions, proof) =
1565            establish(inputs, &session, existing).await?;
1566
1567        // A team switch would let one conversation carry another team's
1568        // transcript into this one. The credential was verified and is
1569        // dropped unused. Checked against the window's identity, not its
1570        // connection: a window off the bus after a failed adoption has the
1571        // same transcript and is bound to the same team.
1572        {
1573            let st = self.state.read().await;
1574            if let Some(old) = st.identity()
1575                && old.team != team
1576            {
1577                let _ = remote.cancel().await;
1578                anyhow::bail!(
1579                    "this conversation is bound to team '{}'; the profile '{}' belongs to team \
1580                     '{team}'. Switching teams inside a conversation is not allowed — the \
1581                     transcript already holds '{}' material. Start a new conversation with \
1582                     that profile instead",
1583                    old.team,
1584                    resolved.profile.as_deref().unwrap_or("?"),
1585                    old.team
1586                );
1587            }
1588        }
1589
1590        // Project and channel defaults from the project file, unless the
1591        // caller set them explicitly.
1592        {
1593            let mut st = self.state.write().await;
1594            if st.project.is_none() {
1595                st.project = resolved.project.clone();
1596            }
1597            if st.channel.is_none() {
1598                st.channel = resolved.channel.clone();
1599            }
1600        }
1601
1602        // Barrier: cancel the old context's in-flight calls, wait for them to
1603        // leave, then swap. Nothing is replayed.
1604        let previous = {
1605            let old = {
1606                let mut st = self.state.write().await;
1607                st.connected.take()
1608            };
1609            match old {
1610                Some(old) => {
1611                    old.ct.cancel();
1612                    let started = std::time::Instant::now();
1613                    while self.in_flight.load(Ordering::SeqCst) > 0
1614                        && started.elapsed() < DRAIN_TIMEOUT
1615                    {
1616                        tokio::time::sleep(Duration::from_millis(20)).await;
1617                    }
1618                    let held = report_holdings(&old.remote, &old.agent, &old.team, &session).await;
1619                    // This window is no longer that identity, so its session
1620                    // is closed rather than left live for nobody: a session
1621                    // with no process behind it blocks its own label and
1622                    // keeps a credential valid for a day. Claims and locks
1623                    // are NOT transferred — they stay with the old identity
1624                    // and expire on their leases, which is what `held`
1625                    // reports back to the caller.
1626                    if old.proof.is_some() {
1627                        let _ = tokio::time::timeout(
1628                            EXIT_TIMEOUT,
1629                            call_remote(&old.remote, "revoke_session", json!({})),
1630                        )
1631                        .await;
1632                    }
1633                    // The old identity goes quiet in its own name.
1634                    let _ = tokio::time::timeout(
1635                        EXIT_TIMEOUT,
1636                        call_remote(
1637                            &old.remote,
1638                            "heartbeat",
1639                            json!({"status": "idle", "ttl_seconds": 30}),
1640                        ),
1641                    )
1642                    .await;
1643                    close_remote(old.remote).await;
1644                    Some(held)
1645                }
1646                None => None,
1647            }
1648        };
1649
1650        let connected = Connected {
1651            resolved,
1652            agent,
1653            team,
1654            remote: Arc::new(remote),
1655            tools,
1656            remote_instructions: instructions,
1657            proof,
1658            ct: CancellationToken::new(),
1659        };
1660        {
1661            let mut st = self.state.write().await;
1662            st.connected = Some(connected);
1663            st.disconnected_reason = None;
1664            st.stranded = None;
1665            st.generation += 1;
1666        }
1667        // A stored permit, not a broadcast: a keepalive that is not waiting
1668        // at this instant still sees the change on its next select.
1669        self.wake.notify_one();
1670        self.heartbeat("active").await;
1671        self.write_binding().await;
1672        Ok(previous)
1673    }
1674
1675    /// Presence for this window: status, repo/branch from the checkout, and
1676    /// the discovery labels. Best effort.
1677    async fn heartbeat(&self, status: &str) {
1678        self.publish_presence(json!({"status": status})).await;
1679    }
1680
1681    /// Keep this window's presence alive without saying anything about its
1682    /// status: a live process is not news about the model (#221). Carries
1683    /// the model's attention when something new was seen, or re-reports a
1684    /// wait still in flight.
1685    async fn refresh_presence(&self) {
1686        let due = self.attention.lock().ok().and_then(|mut a| a.due(true));
1687        let args = match due {
1688            Some(report) => report.fields(),
1689            None => json!({"refresh_only": true}),
1690        };
1691        if self.publish_presence(args).await
1692            && let Some(report) = due
1693            && let Ok(mut a) = self.attention.lock()
1694        {
1695            a.sent(report);
1696        }
1697    }
1698
1699    /// Report attention now if something new was seen; nothing otherwise.
1700    async fn report_attention(&self) {
1701        let due = self.attention.lock().ok().and_then(|mut a| a.due(false));
1702        let Some(report) = due else { return };
1703        if self.publish_presence(report.fields()).await
1704            && let Ok(mut a) = self.attention.lock()
1705        {
1706            a.sent(report);
1707        }
1708    }
1709
1710    /// One heartbeat with `fields`, plus repo/branch and this window's
1711    /// labels. True when the bus accepted it.
1712    async fn publish_presence(&self, fields: Value) -> bool {
1713        let _ordered = self.presence.lock().await;
1714        let (remote, project, role, dir) = {
1715            let st = self.state.read().await;
1716            let Some(c) = &st.connected else { return false };
1717            let dir = st
1718                .roots_dir
1719                .clone()
1720                .unwrap_or_else(|| self.project_dir.clone());
1721            (c.remote.clone(), st.project.clone(), st.role.clone(), dir)
1722        };
1723        let (repo, branch) = git_place(&dir);
1724        let mut args = fields;
1725        args["ttl_seconds"] = json!(PRESENCE_TTL_SECS);
1726        if let Some(r) = repo {
1727            args["repo"] = Value::String(r);
1728        }
1729        if let Some(b) = branch {
1730            args["branch"] = Value::String(b);
1731        }
1732        // Omitted keeps, "" clears: send exactly what this window knows.
1733        args["project"] = Value::String(project.unwrap_or_default());
1734        args["role"] = Value::String(role.unwrap_or_default());
1735        match call_remote(&remote, "heartbeat", args).await {
1736            Ok(_) => true,
1737            Err(e) => {
1738                tracing::warn!(error = %e, "heartbeat failed");
1739                false
1740            }
1741        }
1742    }
1743
1744    /// Record this instance's binding so lifecycle hooks of the same
1745    /// conversation can find the session and labels. Keyed by the
1746    /// conversation id when there is one (hooks know it), by the session
1747    /// otherwise (nothing else can look it up, but `session_status` can
1748    /// still say where it is).
1749    async fn write_binding(&self) {
1750        let st = self.state.read().await;
1751        // Keyed by the conversation id when the host gives one — that is what
1752        // a hook of the same conversation can look up — and by the session
1753        // label otherwise, where nothing else can find it anyway.
1754        let key = st.host_id.clone().unwrap_or_else(|| st.session.clone());
1755        // The connected context, else the one a failed adoption retired:
1756        // its secret is the only one the bus still accepts for this window.
1757        let identity = st.identity();
1758        let (profile, agent, team, mcp_url, proof) = match identity {
1759            Some(i) => (
1760                i.profile,
1761                Some(i.agent),
1762                Some(i.team),
1763                Some(i.mcp_url),
1764                i.proof,
1765            ),
1766            None => (None, None, None, None, None),
1767        };
1768        // The credential goes in here, which is why the file is 0600 inside a
1769        // 0700 directory and why `context hook` is the only thing that reads
1770        // it. It never reaches a tool result, a log or argv.
1771        let record = json!({
1772            "host_id_present": st.host_id.is_some(),
1773            "binding": st.binding,
1774            "session": st.session,
1775            "project": st.project,
1776            "role": st.role,
1777            "channel": st.channel,
1778            "profile": profile,
1779            "agent": agent,
1780            "team": team,
1781            "mcp_url": mcp_url,
1782            "session_token": proof.as_ref().map(|p| p.token.clone()),
1783            "session_id": proof.as_ref().map(|p| p.session_id.clone()),
1784            // Hooks send this epoch, so they are fenced with their proxy
1785            // rather than fencing it: a hook never bumps it.
1786            "epoch": proof.as_ref().map(|p| p.epoch),
1787            "expires_at": proof.as_ref().map(|p| p.expires_at.clone()),
1788            "proxy_pid": std::process::id(),
1789            "updated_at": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1790        });
1791        drop(st);
1792        // Under the same lock as every other writer of this record: a
1793        // renewal stamping the expiry checks ownership and writes inside
1794        // that lock, so this replacement cannot slip in between.
1795        let path = context::binding_path(&self.opts.state_dir, &key);
1796        let text = record.to_string();
1797        if let Err(e) = under_config_lock(self.opts.state_dir.clone(), move || {
1798            context::write_binding_file(&path, &text)
1799        })
1800        .await
1801        {
1802            tracing::warn!(error = %e, "could not write the session binding");
1803        }
1804    }
1805
1806    async fn status(&self) -> Status {
1807        let reported = self.attention.lock().ok().and_then(|a| a.reported);
1808        let st = self.state.read().await;
1809        let c = st.connected.as_ref();
1810        Status {
1811            connected: c.is_some(),
1812            error: st.disconnected_reason.clone(),
1813            agent: c.map(|c| c.agent.clone()),
1814            team: c.map(|c| c.team.clone()),
1815            session: st.session.clone(),
1816            address: c.map(|c| format!("{}/{}", c.agent, st.session)),
1817            project: st.project.clone(),
1818            role: st.role.clone(),
1819            channel: st.channel.clone(),
1820            profile: c.and_then(|c| c.resolved.profile.clone()),
1821            credential_from: c.map(|c| c.resolved.credential_provenance()),
1822            binding: st.binding,
1823            project_root: c
1824                .and_then(|c| c.resolved.project_root.as_ref())
1825                .map(|p| p.display().to_string()),
1826            project_root_from: c.and_then(|c| c.resolved.project_root.as_ref()).map(|_| {
1827                if st.roots_dir.is_some() {
1828                    "mcp-roots"
1829                } else if self.opts.inputs.project_dir.is_some() {
1830                    "project-dir"
1831                } else {
1832                    "working-directory"
1833                }
1834                .to_owned()
1835            }),
1836            bus: c.map(|c| c.resolved.mcp_url.clone()),
1837            attention: reported.map(|(s, _)| s.to_owned()),
1838            attention_reported_seconds_ago: reported.map(|(_, at)| at.elapsed().as_secs()),
1839        }
1840    }
1841
1842    async fn configure(&self, args: ConfigureArgs) -> anyhow::Result<ConfigureResult> {
1843        // Labels first, before anything is committed anywhere.
1844        let mut previous = None;
1845        // Validated like the server validates them, and staged rather than
1846        // committed: a call that also switches profile must leave the
1847        // previous context *entirely* intact when the switch fails, labels
1848        // included.
1849        let staged_role = match args.role {
1850            Some(v) => Some(check_label("role", &v).map_err(|e| anyhow::anyhow!("{}", e.message))?),
1851            None => None,
1852        };
1853        let staged_project = match args.project {
1854            Some(v) => {
1855                Some(check_label("project", &v).map_err(|e| anyhow::anyhow!("{}", e.message))?)
1856            }
1857            None => None,
1858        };
1859        let staged_channel = match args.channel {
1860            Some(v) => {
1861                Some(check_label("channel", &v).map_err(|e| anyhow::anyhow!("{}", e.message))?)
1862            }
1863            None => None,
1864        };
1865
1866        if let Some(profile) = args
1867            .profile
1868            .map(|p| p.trim().to_owned())
1869            .filter(|p| !p.is_empty())
1870        {
1871            let mut inputs = self.inputs(&*self.state.read().await);
1872            inputs.profile = Some(profile);
1873            // A profile switch is a switch of credentials: explicit ones from
1874            // the environment would otherwise win and make the call a no-op.
1875            inputs.explicit_token = None;
1876            inputs.explicit_url = None;
1877            previous = self.connect_with(&inputs, false).await?;
1878        }
1879        // Only now, with the switch (if any) verified and committed.
1880        {
1881            let mut st = self.state.write().await;
1882            if let Some(role) = staged_role {
1883                st.role = role;
1884            }
1885            if let Some(project) = staged_project {
1886                st.project = project;
1887            }
1888            if let Some(channel) = staged_channel {
1889                st.channel = channel;
1890            }
1891        }
1892        self.heartbeat("active").await;
1893        self.write_binding().await;
1894        Ok(ConfigureResult {
1895            status: self.status().await,
1896            previous,
1897        })
1898    }
1899
1900    /// Bind to the conversation a request says it belongs to. First id seen
1901    /// becomes the binding when there was none; a different id later means
1902    /// the host multiplexes conversations over one process, which this proxy
1903    /// does not support and says so rather than mixing them.
1904    async fn observe_meta(&self, meta: &rmcp::model::RequestMetaObject) -> Result<(), ErrorData> {
1905        let id_at = |key: &str| {
1906            meta.0
1907                .0
1908                .get(key)
1909                .and_then(Value::as_str)
1910                .map(str::trim)
1911                .filter(|s| !s.is_empty())
1912        };
1913        // Antigravity names the conversation under its own key on every
1914        // tools/call (verified with agy 1.4.3, which does not export it to
1915        // MCP servers' environment); Codex sends threadId.
1916        let (thread, source) = match id_at(ANTIGRAVITY_META_KEY) {
1917            Some(id) => (id, Binding::Antigravity),
1918            None => match id_at("threadId").or_else(|| id_at("sessionId")) {
1919                Some(id) => (id, Binding::RequestMeta),
1920                None => return Ok(()),
1921            },
1922        };
1923        let (bound, current, by_request) = {
1924            let st = self.state.read().await;
1925            (st.host_id.clone(), st.binding, st.bound_by_request)
1926        };
1927        match bound {
1928            Some(id) if id == thread => Ok(()),
1929            Some(_) if by_request && current == source => Err(ErrorData::invalid_request(
1930                "this proxy instance is bound to another conversation; a second one is using \
1931                 the same MCP process, which is not supported. Configure the host to start \
1932                 one `ai-crew-sync mcp proxy` per conversation",
1933                None,
1934            )),
1935            // Bound by environment or flag: the request's id is informative
1936            // only, the operator's binding wins.
1937            Some(_) => Ok(()),
1938            None => self.rebind(thread.to_owned(), source).await.map_err(|e| {
1939                ErrorData::internal_error(format!("could not bind the conversation: {e:#}"), None)
1940            }),
1941        }
1942    }
1943
1944    /// Adopt a conversation id discovered on the wire: derive the stable
1945    /// session and reconnect so every forwarded call, this one included,
1946    /// carries it.
1947    async fn rebind(&self, host_id: String, source: Binding) -> anyhow::Result<()> {
1948        // Staged, not committed: if the new session cannot connect, the
1949        // previous one must keep serving. Advertising the new identity over
1950        // the old connection is how a window ends up reporting one session
1951        // and writing as another.
1952        let previous = {
1953            let st = self.state.read().await;
1954            (
1955                st.host_id.clone(),
1956                st.binding,
1957                st.session.clone(),
1958                st.bound_by_request,
1959            )
1960        };
1961        {
1962            let mut st = self.state.write().await;
1963            st.host_id = Some(host_id.clone());
1964            st.binding = source;
1965            st.bound_by_request = true;
1966            st.session = session_for(&host_id);
1967        }
1968        // The profile this window speaks as, connected or not: a window off
1969        // the bus reconnects as itself, not as the startup default.
1970        let inputs = {
1971            let st = self.state.read().await;
1972            match st.identity() {
1973                Some(id) => {
1974                    let mut i = self.inputs(&st);
1975                    i.profile = id.profile;
1976                    i
1977                }
1978                None => self.inputs(&st),
1979            }
1980        };
1981        match self.connect_with(&inputs, true).await {
1982            Ok(_) => Ok(()),
1983            Err(e) => {
1984                let mut st = self.state.write().await;
1985                (st.host_id, st.binding, st.session, st.bound_by_request) = previous;
1986                st.disconnected_reason = Some(format!("{e:#}"));
1987                Err(e)
1988            }
1989        }
1990    }
1991
1992    /// Forward one call to the connected context, racing the host's own
1993    /// cancellation and the context's replacement.
1994    /// Spool the references a fetch returned, fsync, then confirm them.
1995    ///
1996    /// Everything here is best effort in one direction only: a reference
1997    /// that cannot be written to disk is **not** confirmed, so the bus keeps
1998    /// offering it. The model still sees it in this turn — it is in the
1999    /// result either way — but nothing claims durability that does not
2000    /// exist.
2001    async fn spool_and_confirm(
2002        &self,
2003        result: CallToolResult,
2004        host_ct: CancellationToken,
2005    ) -> CallToolResult {
2006        let Some(structured) = result.structured_content.clone() else {
2007            return result;
2008        };
2009        let session = self.state.read().await.session.clone();
2010        let path = crate::spool::spool_path(&self.opts.state_dir, &session);
2011
2012        let mut entries: Vec<crate::spool::Entry> = structured
2013            .get("references")
2014            .and_then(|v| v.as_array())
2015            .map(|refs| {
2016                refs.iter()
2017                    .filter_map(|r| {
2018                        Some(crate::spool::Entry {
2019                            delivery_id: r.get("delivery_id")?.as_str()?.to_owned(),
2020                            message_id: r.get("message_id")?.as_str()?.to_owned(),
2021                            conversation_id: r
2022                                .get("conversation_id")
2023                                .and_then(|v| v.as_str())
2024                                .unwrap_or_default()
2025                                .to_owned(),
2026                            seq: r.get("seq").and_then(|v| v.as_i64()).unwrap_or(0),
2027                            from_address: r
2028                                .get("from_address")
2029                                .and_then(|v| v.as_str())
2030                                .unwrap_or_default()
2031                                .to_owned(),
2032                            created_at: r
2033                                .get("created_at")
2034                                .and_then(|v| v.as_str())
2035                                .unwrap_or_default()
2036                                .to_owned(),
2037                            confirmed: false,
2038                            spooled_at: chrono::Utc::now().to_rfc3339(),
2039                        })
2040                    })
2041                    .collect::<Vec<_>>()
2042            })
2043            .unwrap_or_default();
2044
2045        // Anything a previous process spooled and never confirmed goes in
2046        // the same confirmation: that is what an interrupted delivery looks
2047        // like from here.
2048        let mut held = crate::spool::read(&path);
2049        let spooled = match crate::spool::append(&path, &entries) {
2050            Ok(written) => written,
2051            Err(e) => {
2052                tracing::warn!(error = %e, "could not spool inbox references; not confirming");
2053                return result;
2054            }
2055        };
2056        held.extend(spooled.iter().cloned());
2057        entries.clear();
2058        let to_confirm = crate::spool::unconfirmed(&held);
2059        if to_confirm.is_empty() {
2060            return result;
2061        }
2062
2063        let mut params = rmcp::model::JsonObject::new();
2064        params.insert(
2065            "delivery_ids".into(),
2066            Value::Array(
2067                to_confirm
2068                    .iter()
2069                    .map(|id| Value::String(id.clone()))
2070                    .collect(),
2071            ),
2072        );
2073        let confirm =
2074            CallToolRequestParams::new("confirm_inbox_delivery".to_string()).with_arguments(params);
2075        match self.forward(confirm, host_ct).await {
2076            Ok(confirmation) => {
2077                // The bus says which ids it committed now and which of ours
2078                // it had already: both are settled. An id in neither (a
2079                // stale epoch, somebody else's reference) stays in the
2080                // spool as the only evidence that it is still owed. The
2081                // reply the caller gets is the fetch, never this one: the
2082                // confirmation is the proxy's business.
2083                let settled = settled_ids(confirmation.structured_content.as_ref(), &to_confirm);
2084                let sent: std::collections::HashSet<&String> = to_confirm.iter().collect();
2085                let mut left = 0usize;
2086                for entry in held.iter_mut() {
2087                    if sent.contains(&entry.delivery_id) {
2088                        if settled.contains(&entry.delivery_id) {
2089                            entry.confirmed = true;
2090                        } else {
2091                            left += 1;
2092                        }
2093                    }
2094                }
2095                if left > 0 {
2096                    tracing::warn!(
2097                        sent = to_confirm.len(),
2098                        left,
2099                        "the bus did not settle every reference sent; keeping the rest in \
2100                         the spool"
2101                    );
2102                }
2103                if let Err(e) = crate::spool::rewrite(&path, &held) {
2104                    // The bus has the truth; this only costs a repeated
2105                    // confirmation next time, which the bus answers with
2106                    // `already_confirmed`.
2107                    tracing::warn!(error = %e, "could not compact the inbox spool");
2108                }
2109            }
2110            Err(e) => tracing::warn!(error = %e, "could not confirm inbox delivery"),
2111        }
2112        result
2113    }
2114
2115    async fn forward(
2116        &self,
2117        request: CallToolRequestParams,
2118        host_ct: CancellationToken,
2119    ) -> Result<CallToolResult, ErrorData> {
2120        self.forward_bound(request, host_ct, None).await
2121    }
2122
2123    /// `forward`, refusing to run unless the connection is still the one of
2124    /// `bound` (a generation). A call that spans several requests (a long
2125    /// wait) passes the generation it started under, checked under the same
2126    /// lock that hands out the connection, so it can never carry on as an
2127    /// identity the window switched to midway.
2128    async fn forward_bound(
2129        &self,
2130        request: CallToolRequestParams,
2131        host_ct: CancellationToken,
2132        bound: Option<u64>,
2133    ) -> Result<CallToolResult, ErrorData> {
2134        let (remote, ct, generation, _guard) = {
2135            let st = self.state.read().await;
2136            if let Some(started) = bound
2137                && st.generation != started
2138            {
2139                return Err(ErrorData::invalid_request(
2140                    format!(
2141                        "{} was cancelled: this window switched credentials while the call was \
2142                         in flight (generation {started}). Nothing was replayed; call again if \
2143                         it is still wanted, as the new identity",
2144                        request.name
2145                    ),
2146                    None,
2147                ));
2148            }
2149            let Some(c) = &st.connected else {
2150                return Err(ErrorData::invalid_request(
2151                    format!(
2152                        "not connected to the bus: {}. Call {CONFIGURE_TOOL} with an approved \
2153                         profile, or fix the local configuration and start a new conversation",
2154                        st.disconnected_reason
2155                            .as_deref()
2156                            .unwrap_or("no profile resolved")
2157                    ),
2158                    None,
2159                ));
2160            };
2161            (
2162                c.remote.clone(),
2163                c.ct.clone(),
2164                st.generation,
2165                InFlight::enter(&self.in_flight),
2166            )
2167        };
2168        let name = request.name.to_string();
2169        let profile = {
2170            let st = self.state.read().await;
2171            st.connected
2172                .as_ref()
2173                .and_then(|c| c.resolved.profile.clone())
2174        };
2175        let outcome = tokio::select! {
2176            r = remote.call_tool(request) => r.map_err(|e| {
2177                // A rejected bearer says "Auth required" and nothing about
2178                // what to do. This is what a revoked or rotated token looks
2179                // like from here; see `unauthorized` for what it is not.
2180                if unauthorized(&e) {
2181                    self.mark_unauthorized(&profile, generation);
2182                    ErrorData::invalid_request(
2183                        format!(
2184                            "{name}: the bus rejected this window's credential — it has been \
2185                             revoked or rotated{}. Issue a new token (`ai-crew-sync admin \
2186                             token issue --save`) and call {CONFIGURE_TOOL} with an approved \
2187                             profile; nothing was sent",
2188                            profile
2189                                .as_deref()
2190                                .map(|p| format!(" (profile '{p}')"))
2191                                .unwrap_or_default()
2192                        ),
2193                        None,
2194                    )
2195                } else if let ServiceError::McpError(data) = e {
2196                    // The bus wrote this for the model: same code, same
2197                    // words, same data, exactly as a direct call gets it.
2198                    data
2199                } else if let Some(r) = refusal(&e) {
2200                    // The bus answered and ran nothing: its middleware's
2201                    // words, and the caller knows the call did not happen.
2202                    tracing::warn!(error = %e, tool = %name, "the bus refused a forwarded call");
2203                    ErrorData::invalid_request(format!("{name}: {}", r.text()), None)
2204                } else {
2205                    // This proxy lost the bus. One classification, and the
2206                    // fact the caller needs to act safely: whether the call
2207                    // ran is unknown. The transport's detail goes to the log.
2208                    tracing::warn!(error = %e, tool = %name, "a forwarded call failed in transport");
2209                    ErrorData::internal_error(
2210                        format!(
2211                            "{name} could not reach the bus: the connection failed before an \
2212                             answer came back, so the call may or may not have run. Check \
2213                             before repeating anything that is not safe to repeat."
2214                        ),
2215                        None,
2216                    )
2217                }
2218            }),
2219            _ = ct.cancelled() => Err(ErrorData::invalid_request(
2220                format!(
2221                    "{name} was cancelled: this window switched credentials while the call was \
2222                     in flight (generation {generation}). Nothing was replayed; call again if \
2223                     it is still wanted, as the new identity"
2224                ),
2225                None,
2226            )),
2227            _ = host_ct.cancelled() => Err(ErrorData::invalid_request(
2228                format!("{name} was cancelled by the client"),
2229                None,
2230            )),
2231        };
2232        // The bus answers this proxy's client on its own negotiated
2233        // revision, so a deserialized result may carry no `resultType`.
2234        // Protocol `2026-07-28` requires it on every result the host
2235        // reads, and absent-means-complete holds only for earlier
2236        // revisions: a call the bus finished is marked complete here.
2237        outcome.map(|mut result| {
2238            result
2239                .result_type
2240                .get_or_insert(rmcp::model::ResultType::COMPLETE);
2241            result
2242        })
2243    }
2244
2245    /// `wait_for_updates` with a `timeout_seconds` above what the bus serves
2246    /// in one request: wait on the bus in slices until something happens or
2247    /// the whole time has passed, and answer once. Up to the bus's own limit
2248    /// the call is forwarded unchanged.
2249    async fn long_wait(
2250        &self,
2251        request: CallToolRequestParams,
2252        host_ct: CancellationToken,
2253    ) -> Result<CallToolResult, ErrorData> {
2254        let requested = request
2255            .arguments
2256            .as_ref()
2257            .and_then(|a| a.get("timeout_seconds"))
2258            .and_then(Value::as_i64);
2259        let Some(total) = requested.filter(|t| *t > SERVER_WAIT_LIMIT_SECS) else {
2260            return self.forward(request, host_ct).await;
2261        };
2262        // The whole wait belongs to the identity it started under.
2263        let started_under = self.state.read().await.generation;
2264        // Each slice resumes where the previous one stopped, so a task, lock
2265        // or note change between two slices is replayed, not lost (#211).
2266        let mut since = request
2267            .arguments
2268            .as_ref()
2269            .and_then(|a| a.get("since"))
2270            .and_then(Value::as_str)
2271            .map(str::to_owned);
2272        let deadline = std::time::Instant::now()
2273            + std::time::Duration::from_secs(total.min(PROXY_MAX_WAIT_SECS) as u64);
2274        loop {
2275            let left = deadline
2276                .saturating_duration_since(std::time::Instant::now())
2277                .as_secs() as i64;
2278            let mut slice = request.clone();
2279            let args = slice.arguments.get_or_insert_with(Default::default);
2280            args.insert(
2281                "timeout_seconds".into(),
2282                json!(left.clamp(5, SERVER_WAIT_SLICE_SECS)),
2283            );
2284            if let Some(position) = &since {
2285                args.insert("since".into(), json!(position));
2286            }
2287            let result = self
2288                .forward_bound(slice, host_ct.clone(), Some(started_under))
2289                .await?;
2290            let woke = result
2291                .structured_content
2292                .as_ref()
2293                .and_then(|v| v["woke"].as_bool())
2294                .unwrap_or(true);
2295            if woke || left <= SERVER_WAIT_SLICE_SECS || host_ct.is_cancelled() {
2296                return Ok(result);
2297            }
2298            since = result
2299                .structured_content
2300                .as_ref()
2301                .and_then(|v| v["resume_from"].as_str().map(str::to_owned))
2302                .or(since);
2303        }
2304    }
2305
2306    /// Record that the bus refused this window's credential, so
2307    /// `session_status` and the next `initialize` say so instead of
2308    /// claiming a healthy connection. Non-blocking: a busy lock means the
2309    /// next call reports it. Only for the context the refused call ran
2310    /// in: a call that was still in flight on a connection this window has
2311    /// since replaced (a resume, a profile switch) says nothing about the
2312    /// credential it holds now.
2313    fn mark_unauthorized(&self, profile: &Option<String>, generation: u64) {
2314        if let Ok(mut st) = self.state.try_write()
2315            && st.generation == generation
2316        {
2317            st.disconnected_reason = Some(format!(
2318                "the bus rejected the credential{} (revoked or rotated)",
2319                profile
2320                    .as_deref()
2321                    .map(|p| format!(" of profile '{p}'"))
2322                    .unwrap_or_default()
2323            ));
2324        }
2325    }
2326
2327    /// `resume_session` asked for through this window.
2328    ///
2329    /// Forwarded as is, the bus rotates the secret and bumps the epoch of
2330    /// the credential this proxy sends, hands the new secret to the model,
2331    /// and every later call — this proxy's, its hooks', its next restart's —
2332    /// presents the old one and is refused. So the proxy runs the resume
2333    /// with the credential it holds, adopts what comes back exactly as a
2334    /// reconnect does (new connection, binding rewritten), and answers
2335    /// with the credential's description, never its secret. `None` when
2336    /// this window holds no credential (a bus that issues none, or no
2337    /// connection): the call is forwarded and the bus answers it. The error
2338    /// is the sentence the model reads; the detail behind it is logged.
2339    async fn resume_in_place(&self) -> Option<Result<Value, String>> {
2340        let _guard = self.switch.lock().await;
2341        let (url, proof, session) = {
2342            let st = self.state.read().await;
2343            let c = st.connected.as_ref()?;
2344            let proof = c.proof.clone()?;
2345            (c.resolved.mcp_url.clone(), proof, st.session.clone())
2346        };
2347        Some(self.adopt_resumed(&url, &proof, &session).await)
2348    }
2349
2350    async fn adopt_resumed(
2351        &self,
2352        url: &str,
2353        prior: &SessionProof,
2354        session: &str,
2355    ) -> Result<Value, String> {
2356        let proof = rotate(url, prior, session).await?;
2357        // From here the bus refuses the old secret. A connection on the new
2358        // one gets a few tries: one transient failure must not cost the
2359        // window the bus.
2360        let mut attempt = 1;
2361        let opened = loop {
2362            match open_with(url, &proof, session).await {
2363                Ok(opened) => break Ok(opened),
2364                Err(why) if attempt < ADOPT_ATTEMPTS => {
2365                    tracing::warn!(
2366                        attempt,
2367                        "the resumed credential could not open a connection ({why}); retrying"
2368                    );
2369                    tokio::time::sleep(Duration::from_millis(250 * 4u64.pow(attempt - 1))).await;
2370                    attempt += 1;
2371                }
2372                Err(why) => break Err(why),
2373            }
2374        };
2375        let old = {
2376            let mut st = self.state.write().await;
2377            let Some(c) = st.connected.take() else {
2378                return Err(
2379                    "resume_session: the session was resumed, but this window lost its \
2380                     connection meanwhile; restart this MCP server to reconnect it"
2381                        .to_owned(),
2382                );
2383            };
2384            match opened {
2385                Ok((remote, tools)) => {
2386                    let instructions = remote.peer_info().and_then(|i| i.instructions.clone());
2387                    st.connected = Some(Connected {
2388                        resolved: c.resolved.clone(),
2389                        agent: c.agent.clone(),
2390                        team: c.team.clone(),
2391                        remote: Arc::new(remote),
2392                        tools,
2393                        remote_instructions: instructions,
2394                        proof: Some(proof.clone()),
2395                        ct: CancellationToken::new(),
2396                    });
2397                    st.disconnected_reason = None;
2398                }
2399                Err(why) => {
2400                    // The old secret is refused and the new one opens no
2401                    // connection: serving on the old connection would only
2402                    // collect refusals while reporting a healthy window. It
2403                    // is retired, and the new credential stays in the
2404                    // binding, where a restart resumes with it.
2405                    st.stranded = Some(Identity {
2406                        profile: c.resolved.profile.clone(),
2407                        agent: c.agent.clone(),
2408                        team: c.team.clone(),
2409                        mcp_url: c.resolved.mcp_url.clone(),
2410                        proof: Some(proof.clone()),
2411                    });
2412                    let said = format!(
2413                        "the session was resumed and the bus rotated this window's credential, \
2414                         but the new one could not open a connection ({why}); it is saved, and \
2415                         this window is off the bus until this MCP server is restarted, which \
2416                         reconnects with it"
2417                    );
2418                    st.disconnected_reason = Some(said.clone());
2419                    st.generation += 1;
2420                    drop(st);
2421                    c.ct.cancel();
2422                    close_remote(c.remote).await;
2423                    self.wake.notify_one();
2424                    self.write_binding().await;
2425                    return Err(format!("resume_session: {said}"));
2426                }
2427            }
2428            st.generation += 1;
2429            c
2430        };
2431        // Calls still in flight on the old connection are refused by the
2432        // bus from now on; cancel them rather than let them report a
2433        // rejected credential the window no longer has.
2434        old.ct.cancel();
2435        close_remote(old.remote).await;
2436        self.wake.notify_one();
2437        self.write_binding().await;
2438        let expires_in_seconds = chrono::DateTime::parse_from_rfc3339(&proof.expires_at)
2439            .map(|t| (t.with_timezone(&chrono::Utc) - chrono::Utc::now()).num_seconds())
2440            .unwrap_or(0)
2441            .max(0);
2442        // The bus's own shape, without the secret: the proxy holds it.
2443        let described = crate::model::SessionCredential {
2444            session_token: None,
2445            session_id: proof.session_id,
2446            session: session.to_owned(),
2447            address: format!("{}/{session}", old.agent),
2448            epoch: proof.epoch,
2449            expires_at: proof.expires_at,
2450            expires_in_seconds,
2451        };
2452        serde_json::to_value(described).map_err(|e| {
2453            tracing::warn!(error = %e, "could not describe the resumed credential");
2454            "resume_session: the session was resumed and this window holds the new \
2455             credential, but its description could not be built; session_status shows it"
2456                .to_owned()
2457        })
2458    }
2459
2460    /// The channel this window posts to when a message names none: its own
2461    /// `channel`, else the channel named after its project.
2462    async fn default_channel(&self) -> Option<String> {
2463        let st = self.state.read().await;
2464        st.channel.clone().or_else(|| st.project.clone())
2465    }
2466
2467    fn instructions(&self, st: &State) -> String {
2468        let mut lines = Vec::new();
2469        match &st.connected {
2470            Some(c) => {
2471                lines.push(format!(
2472                    "[ai-crew-sync] You are agent '{}' on team '{}', in session '{}'. Teammates \
2473                     reach exactly this window at '{}/{}'.",
2474                    c.agent, c.team, st.session, c.agent, st.session
2475                ));
2476                lines.push(format!(
2477                    "- project: {}, role: {}, default channel: {}. Change them with \
2478                     {CONFIGURE_TOOL}; see them with {STATUS_TOOL}. Find teammates' windows \
2479                     with list_sessions.",
2480                    st.project.as_deref().unwrap_or("(none — set it)"),
2481                    st.role.as_deref().unwrap_or("(none — set it)"),
2482                    st.channel
2483                        .as_deref()
2484                        .or(st.project.as_deref())
2485                        .unwrap_or("(none)"),
2486                ));
2487                lines.push(
2488                    "- Nothing is pushed into an idle turn: call read_messages or wait_for_updates \
2489                     to receive what teammates sent."
2490                        .to_owned(),
2491                );
2492                if let Some(remote) = &c.remote_instructions {
2493                    lines.push(String::new());
2494                    lines.push(remote.clone());
2495                }
2496            }
2497            None => {
2498                lines.push(format!(
2499                    "[ai-crew-sync] Not connected to the team bus: {}. Only {CONFIGURE_TOOL} and \
2500                     {STATUS_TOOL} are available until a locally approved profile connects.",
2501                    st.disconnected_reason
2502                        .as_deref()
2503                        .unwrap_or("no profile resolved")
2504                ));
2505            }
2506        }
2507        lines.join("\n")
2508    }
2509
2510    /// Periodic presence, and the credential renewed before it expires,
2511    /// until cancelled. Presence is on a fixed cadence; the renewal is
2512    /// scheduled from the expiry the bus last stated, so an idle window is
2513    /// renewed exactly as a busy one.
2514    pub async fn keepalive(self, ct: CancellationToken) {
2515        let mut next_heartbeat = tokio::time::Instant::now() + KEEPALIVE_EVERY;
2516        let mut last_flush = tokio::time::Instant::now() - FLUSH_RETRY;
2517        // Earliest next renewal attempt, whatever the deadline says: keeps a
2518        // deadline already in the past (a bus that will not renew, a reply
2519        // without an expiry) from becoming a tight loop. Tied to the context
2520        // generation it was set for: a profile switch brings a credential of
2521        // its own, whose first renewal must not wait out the old one's
2522        // backoff.
2523        let mut not_before: Option<(u64, tokio::time::Instant)> = None;
2524        loop {
2525            let deadline = self.renewal_deadline().await;
2526            let renew_at = deadline.map(|(generation, at)| match not_before {
2527                Some((for_generation, nb)) if for_generation == generation => at.max(nb),
2528                _ => at,
2529            });
2530            // A call the throttle deferred is flushed once it may be.
2531            // Never sooner than a moment after the last attempt: a bus that
2532            // refuses the report must not turn this into a busy loop.
2533            let flush_at = self
2534                .attention
2535                .lock()
2536                .ok()
2537                .and_then(|a| a.flush_at())
2538                .map(tokio::time::Instant::from_std)
2539                .map(|at| at.max(last_flush + FLUSH_RETRY));
2540            let flush_sleep = tokio::time::sleep_until(
2541                flush_at.unwrap_or_else(|| tokio::time::Instant::now() + KEEPALIVE_EVERY),
2542            );
2543            let renew_sleep = tokio::time::sleep_until(
2544                renew_at.unwrap_or_else(|| tokio::time::Instant::now() + KEEPALIVE_EVERY),
2545            );
2546            tokio::select! {
2547                _ = ct.cancelled() => return,
2548                // A new context: go round and schedule for its credential.
2549                _ = self.wake.notified() => {}
2550                _ = tokio::time::sleep_until(next_heartbeat) => {
2551                    // Renew, never re-assert "active": the window's hooks
2552                    // may have said its turn ended (#221).
2553                    self.refresh_presence().await;
2554                    next_heartbeat = tokio::time::Instant::now() + KEEPALIVE_EVERY;
2555                }
2556                _ = self.attention_changed.notified() => {
2557                    self.report_attention().await;
2558                }
2559                _ = flush_sleep, if flush_at.is_some() => {
2560                    last_flush = tokio::time::Instant::now();
2561                    self.report_attention().await;
2562                }
2563                _ = renew_sleep, if renew_at.is_some() => {
2564                    let pause = match self.renew_credential().await {
2565                        // A refused credential is refused again a moment
2566                        // later; look again on the presence cadence, in
2567                        // case a profile switch brought a live one.
2568                        Renewal::Refused => KEEPALIVE_EVERY,
2569                        Renewal::Renewed | Renewal::Retry | Renewal::Nothing => self.renewal_retry().await,
2570                    };
2571                    if let Some((generation, _)) = deadline {
2572                        not_before = Some((generation, tokio::time::Instant::now() + pause));
2573                    }
2574                }
2575            }
2576        }
2577    }
2578
2579    /// When the credential this window holds should be renewed: its expiry
2580    /// less the lead, never earlier than now, with the context generation
2581    /// the credential belongs to. `None` without a credential.
2582    async fn renewal_deadline(&self) -> Option<(u64, tokio::time::Instant)> {
2583        let (generation, expires_at) = {
2584            let st = self.state.read().await;
2585            let expires_at = st
2586                .connected
2587                .as_ref()
2588                .and_then(|c| c.proof.as_ref())
2589                .map(|p| p.expires_at.clone())?;
2590            (st.generation, expires_at)
2591        };
2592        let expires_at = chrono::DateTime::parse_from_rfc3339(&expires_at).ok()?;
2593        let remaining = (expires_at.with_timezone(&chrono::Utc) - chrono::Utc::now())
2594            .num_seconds()
2595            .max(0);
2596        let due_in = (remaining - self.renewal_lead().await).max(0) as u64;
2597        Some((
2598            generation,
2599            tokio::time::Instant::now() + Duration::from_secs(due_in),
2600        ))
2601    }
2602
2603    async fn renewal_lead(&self) -> i64 {
2604        let lifetime = requested_session_ttl().unwrap_or(crate::auth::SESSION_TTL_SECS);
2605        renewal_lead_secs(lifetime, env_secs(RENEW_LEAD_ENV))
2606    }
2607
2608    /// Gap between renewal attempts: a quarter of the lead, so a transient
2609    /// failure gets several tries before the credential lapses.
2610    async fn renewal_retry(&self) -> Duration {
2611        Duration::from_secs((self.renewal_lead().await / 4).clamp(2, 60) as u64)
2612    }
2613
2614    /// Extend the credential this window holds, without rotating its secret
2615    /// or epoch. Raced against the connected context's cancellation and
2616    /// fenced on the generation, so a profile switch under way discards the
2617    /// answer rather than applying it to the wrong identity.
2618    async fn renew_credential(&self) -> Renewal {
2619        let (remote, proof, generation, ct, profile) = {
2620            let st = self.state.read().await;
2621            let Some(c) = &st.connected else {
2622                return Renewal::Nothing;
2623            };
2624            let Some(p) = &c.proof else {
2625                return Renewal::Nothing;
2626            };
2627            (
2628                c.remote.clone(),
2629                p.clone(),
2630                st.generation,
2631                c.ct.clone(),
2632                c.resolved.profile.clone(),
2633            )
2634        };
2635        let mut args = json!({});
2636        if let Some(ttl) = requested_session_ttl() {
2637            args["ttl_seconds"] = json!(ttl);
2638        }
2639        let outcome = tokio::select! {
2640            _ = ct.cancelled() => return Renewal::Nothing,
2641            r = call_remote(&remote, "renew_session", args) => r,
2642        };
2643        match outcome {
2644            Ok(v) => {
2645                let Some(expires_at) = v["expires_at"].as_str().map(str::to_owned) else {
2646                    tracing::warn!("renew_session answered without an expiry; keeping the old one");
2647                    return Renewal::Retry;
2648                };
2649                if v["epoch"].as_i64().is_some_and(|e| e != proof.epoch) {
2650                    // A renewal never rotates; an answer that says otherwise
2651                    // is not applied to a credential it does not describe.
2652                    tracing::warn!(
2653                        "renew_session answered for another epoch; keeping the credential this \
2654                         window holds"
2655                    );
2656                    return Renewal::Retry;
2657                }
2658                {
2659                    let mut st = self.state.write().await;
2660                    if st.generation != generation {
2661                        return Renewal::Nothing;
2662                    }
2663                    let Some(current) = st.connected.as_mut().and_then(|c| c.proof.as_mut()) else {
2664                        return Renewal::Nothing;
2665                    };
2666                    if current.session_id != proof.session_id || current.epoch != proof.epoch {
2667                        return Renewal::Nothing;
2668                    }
2669                    current.expires_at = expires_at.clone();
2670                }
2671                self.stamp_binding_expiry(&proof, &expires_at).await;
2672                tracing::debug!(expires_at = %expires_at, "session credential renewed");
2673                Renewal::Renewed
2674            }
2675            Err(e) => match verdict_of(&e) {
2676                Some(Verdict::Unauthorized) => {
2677                    tracing::warn!(error = %e, "the bus refused to renew this window's credential");
2678                    self.mark_unauthorized(&profile, generation);
2679                    Renewal::Refused
2680                }
2681                Some(Verdict::NoSuchTool) => {
2682                    tracing::debug!("this bus does not renew credentials");
2683                    Renewal::Nothing
2684                }
2685                None => {
2686                    tracing::warn!(error = %e, "could not renew this window's credential; retrying");
2687                    Renewal::Retry
2688                }
2689            },
2690        }
2691    }
2692
2693    /// Persist a renewed expiry into the binding, **only if the record still
2694    /// describes this credential**: a successor's record is left alone, as
2695    /// in `mark_closed`.
2696    async fn stamp_binding_expiry(&self, proof: &SessionProof, expires_at: &str) {
2697        let key = {
2698            let st = self.state.read().await;
2699            st.host_id.clone().unwrap_or_else(|| st.session.clone())
2700        };
2701        let path = context::binding_path(&self.opts.state_dir, &key);
2702        // Read, check and write under the configuration lock every writer
2703        // of this directory takes: a successor that replaces the record
2704        // between the check and the write would otherwise be overwritten
2705        // with this instance's older credential.
2706        let (session_id, epoch, expires_at) =
2707            (proof.session_id.clone(), proof.epoch, expires_at.to_owned());
2708        let stamped = under_config_lock(self.opts.state_dir.clone(), move || {
2709            let Ok(text) = std::fs::read_to_string(&path) else {
2710                return Ok(false);
2711            };
2712            let Ok(mut value) = serde_json::from_str::<Value>(&text) else {
2713                return Ok(false);
2714            };
2715            let same = value["session_id"].as_str() == Some(session_id.as_str())
2716                && value["epoch"].as_i64() == Some(epoch);
2717            if !same {
2718                return Ok(false);
2719            }
2720            if let Some(map) = value.as_object_mut() {
2721                map.insert("expires_at".into(), json!(expires_at));
2722                map.insert(
2723                    "updated_at".into(),
2724                    json!(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)),
2725                );
2726            }
2727            context::write_binding_file(&path, &value.to_string())?;
2728            Ok(true)
2729        })
2730        .await;
2731        match stamped {
2732            Ok(true) => {}
2733            Ok(false) => tracing::debug!(
2734                binding = %key,
2735                "another instance owns this binding now, or it is gone; not stamping"
2736            ),
2737            Err(e) => {
2738                tracing::warn!(error = %e, binding = %key, "could not record the renewed expiry")
2739            }
2740        }
2741    }
2742
2743    /// Go quiet on the bus; the host is closing this window.
2744    pub async fn shutdown(&self) {
2745        let remote = {
2746            let st = self.state.read().await;
2747            st.connected.as_ref().map(|c| c.remote.clone())
2748        };
2749        if let Some(remote) = remote {
2750            let _ = tokio::time::timeout(
2751                EXIT_TIMEOUT,
2752                call_remote(
2753                    &remote,
2754                    "heartbeat",
2755                    // The host closed this window: nothing will start a turn
2756                    // in it.
2757                    // Nor will anything wake it: withdraw any announcement.
2758                    json!({"status": "idle", "ttl_seconds": 120, "attention": "idle", "wake": ""}),
2759                ),
2760            )
2761            .await;
2762            close_remote(remote).await;
2763        }
2764        // The credential stays on disk so a restart of this conversation can
2765        // resume the window; the record is only stamped as closed, and only
2766        // if it still describes this instance.
2767        self.mark_closed().await;
2768    }
2769
2770    /// Mark this window closed, **only if the record still describes this
2771    /// instance**.
2772    ///
2773    /// Two things were wrong with clearing it unconditionally. A successor
2774    /// proxy for the same conversation may already have replaced the record,
2775    /// and wiping it would cut the live window's hooks off from their own
2776    /// credential. And the credential itself has to stay: a restart of this
2777    /// conversation resumes with it, which is the only way back in — the bus
2778    /// refuses to hand a live session to whoever holds the agent token, and
2779    /// rightly so. It is a 0600 file scoped to one window and it expires on
2780    /// its own.
2781    async fn mark_closed(&self) {
2782        let (key, mine) = {
2783            let st = self.state.read().await;
2784            let key = st.host_id.clone().unwrap_or_else(|| st.session.clone());
2785            // The window's identity, not its connection: one retired by a
2786            // failed adoption still owns the record it wrote, and only that
2787            // record. A window with no identity at all wrote none.
2788            let Some(identity) = st.identity() else {
2789                return;
2790            };
2791            let mine = identity.proof.map(|p| (p.session_id, p.epoch));
2792            (key, mine)
2793        };
2794        let path = context::binding_path(&self.opts.state_dir, &key);
2795        let shown = key.clone();
2796        let outcome = under_config_lock(self.opts.state_dir.clone(), move || {
2797            let Ok(text) = std::fs::read_to_string(&path) else {
2798                return Ok(false);
2799            };
2800            let Ok(mut value) = serde_json::from_str::<Value>(&text) else {
2801                return Ok(false);
2802            };
2803            // Ownership check: a record whose session or epoch has moved on
2804            // belongs to the instance that replaced us.
2805            if let Some((session_id, epoch)) = mine {
2806                let same = value["session_id"].as_str() == Some(session_id.as_str())
2807                    && value["epoch"].as_i64() == Some(epoch);
2808                if !same {
2809                    return Ok(false);
2810                }
2811            }
2812            if let Some(map) = value.as_object_mut() {
2813                map.insert("closed_at".into(), json!(chrono::Utc::now().to_rfc3339()));
2814            }
2815            context::write_binding_file(&path, &value.to_string())?;
2816            Ok(true)
2817        })
2818        .await;
2819        match outcome {
2820            Ok(true) => {}
2821            Ok(false) => tracing::debug!(
2822                binding = %shown,
2823                "another instance owns this binding now, or it is gone; leaving it alone"
2824            ),
2825            Err(e) => {
2826                tracing::warn!(error = %e, binding = %shown, "could not mark the binding closed")
2827            }
2828        }
2829    }
2830}
2831
2832/// Run a binding read-modify-write under the configuration lock, on a
2833/// blocking thread: the lock is a file lock shared with every other writer
2834/// of the directory (other proxies of this conversation included), and a
2835/// wait for it must not stall the runtime that serves the host.
2836async fn under_config_lock<T: Send + 'static>(
2837    dir: PathBuf,
2838    f: impl FnOnce() -> anyhow::Result<T> + Send + 'static,
2839) -> anyhow::Result<T> {
2840    tokio::task::spawn_blocking(move || context::with_config_lock(&dir, f))
2841        .await
2842        .map_err(|e| anyhow::anyhow!("the binding writer task failed: {e}"))?
2843}
2844
2845/// Close a remote connection we may share with an in-flight call. Sole
2846/// owner: cancel cleanly. Otherwise the last holder drops it, and dropping a
2847/// `RunningService` closes it as well.
2848async fn close_remote(remote: Arc<Remote>) {
2849    if let Ok(owned) = Arc::try_unwrap(remote) {
2850        let _ = owned.cancel().await;
2851    }
2852}
2853
2854/// What an identity still holds on the bus, read before it is set aside.
2855async fn report_holdings(
2856    remote: &Remote,
2857    agent: &str,
2858    team: &str,
2859    session: &str,
2860) -> PreviousIdentity {
2861    let open_claims = call_remote(remote, "list_tasks", json!({"mine_only": true}))
2862        .await
2863        .ok()
2864        .and_then(|v| v["tasks"].as_array().cloned())
2865        .unwrap_or_default()
2866        .iter()
2867        .filter(|t| t["status"] == "claimed")
2868        .filter_map(|t| t["key"].as_str().map(str::to_owned))
2869        .collect();
2870    let held_locks = call_remote(remote, "list_locks", json!({}))
2871        .await
2872        .ok()
2873        .and_then(|v| v["locks"].as_array().cloned())
2874        .unwrap_or_default()
2875        .iter()
2876        // Agent AND session: locks are session-scoped, so a sibling window's
2877        // lock is not this one's to report as left behind.
2878        .filter(|l| {
2879            l["holder"] == agent && l["holder_session"].as_str().unwrap_or_default() == session
2880        })
2881        .filter_map(|l| l["name"].as_str().map(str::to_owned))
2882        .collect();
2883    PreviousIdentity {
2884        agent: agent.to_owned(),
2885        team: team.to_owned(),
2886        session: session.to_owned(),
2887        open_claims,
2888        held_locks,
2889    }
2890}
2891
2892/// Why `resume_session` with a lifetime of its own is refused here.
2893const RESUME_TTL_REFUSED: &str = "resume_session: through this proxy a resumed credential keeps \
2894     the lifetime this window renews it with (BUS_SESSION_TTL_SECS, else the bus default), so \
2895     ttl_seconds is not accepted and nothing was resumed; call it without ttl_seconds";
2896
2897/// A bus tool as this proxy serves it. `resume_session` is run by the
2898/// proxy, and its credential lives as long as every other one the window
2899/// holds: register, resume and each renewal ask for `BUS_SESSION_TTL_SECS`,
2900/// and the renewal schedule is computed from it. A lifetime asked for on one
2901/// call would last until the next renewal, so the proxy does not advertise
2902/// `ttl_seconds` and refuses it.
2903fn as_proxied(mut tool: Tool) -> Tool {
2904    if tool.name != "resume_session" {
2905        return tool;
2906    }
2907    let mut schema = (*tool.input_schema).clone();
2908    if let Some(Value::Object(properties)) = schema.get_mut("properties") {
2909        properties.remove("ttl_seconds");
2910    }
2911    if let Some(Value::Array(required)) = schema.get_mut("required") {
2912        required.retain(|r| r != "ttl_seconds");
2913    }
2914    schema.insert("additionalProperties".into(), Value::Bool(false));
2915    tool.input_schema = Arc::new(schema);
2916    let said = tool.description.as_deref().unwrap_or_default();
2917    tool.description = Some(
2918        format!(
2919            "{said} Through this proxy, the proxy runs the resume and keeps the new secret; \
2920             the lifetime is this window's (BUS_SESSION_TTL_SECS), so ttl_seconds is not \
2921             accepted."
2922        )
2923        .trim_start()
2924        .to_owned()
2925        .into(),
2926    );
2927    tool
2928}
2929
2930fn tool_error(msg: String) -> CallToolResult {
2931    CallToolResult::error(vec![rmcp::model::ContentBlock::text(msg)])
2932}
2933
2934impl ServerHandler for Proxy {
2935    async fn initialize(
2936        &self,
2937        request: rmcp::model::InitializeRequestParams,
2938        context: RequestContext<RoleServer>,
2939    ) -> Result<rmcp::model::InitializeResult, ErrorData> {
2940        // Bound and connected before answering, so the instructions the
2941        // host hands the model name the right window.
2942        self.admit(Some(&request.client_info)).await;
2943        if request.capabilities.roots.is_some()
2944            && self.opts.inputs.project_dir.is_none()
2945            && self.opts.inputs.profile.is_none()
2946            && let Ok(gate) = self.roots_gate.clone().try_lock_owned()
2947            && let Ok(mut hold) = self.roots_hold.lock()
2948        {
2949            *hold = Some(gate);
2950        }
2951        context.peer.set_peer_info(request.clone());
2952        self.negotiate_initialize(&request)
2953    }
2954
2955    async fn discover(
2956        &self,
2957        context: RequestContext<RoleServer>,
2958    ) -> Result<rmcp::model::DiscoverResult, ErrorData> {
2959        self.admit(context.meta.client_info().as_ref()).await;
2960        Ok(rmcp::model::DiscoverResult::from_server_info(
2961            self.supported_protocol_versions().into_owned(),
2962            self.get_info(),
2963        ))
2964    }
2965
2966    async fn on_initialized(&self, context: rmcp::service::NotificationContext<RoleServer>) {
2967        // Held since `initialize` when the client advertised roots.
2968        let Some(gate) = self.roots_hold.lock().ok().and_then(|mut h| h.take()) else {
2969            return;
2970        };
2971        let this = self.clone();
2972        tokio::spawn(async move {
2973            this.discover_roots(&context.peer).await;
2974            drop(gate);
2975        });
2976    }
2977
2978    fn get_info(&self) -> ServerConfig {
2979        let mut info = ServerConfig::new(
2980            ServerCapabilities::builder()
2981                .enable_tools()
2982                .enable_tool_list_changed()
2983                .build(),
2984        );
2985        // `get_info` is synchronous; the state lock is uncontended at
2986        // initialize time, and a contended read simply yields the
2987        // disconnected wording until the next call.
2988        let text = match self.state.try_read() {
2989            Ok(st) => self.instructions(&st),
2990            Err(_) => format!("[ai-crew-sync] initialising; call {STATUS_TOOL} for details."),
2991        };
2992        info.instructions = Some(text);
2993        info
2994    }
2995
2996    async fn list_tools(
2997        &self,
2998        _request: Option<PaginatedRequestParams>,
2999        context: RequestContext<RoleServer>,
3000    ) -> Result<ListToolsResult, ErrorData> {
3001        self.admit(context.meta.client_info().as_ref()).await;
3002        self.await_roots().await;
3003        let mut tools = local_tools();
3004        if let Some(c) = &self.state.read().await.connected {
3005            tools.extend(c.tools.iter().cloned().map(as_proxied));
3006        }
3007        Ok(crate::tools::catalogue(tools))
3008    }
3009
3010    async fn call_tool(
3011        &self,
3012        request: CallToolRequestParams,
3013        context: RequestContext<RoleServer>,
3014    ) -> Result<CallToolResponse, ErrorData> {
3015        // The transport lifts `_meta` out of the params into the context.
3016        self.admit(context.meta.client_info().as_ref()).await;
3017        self.await_roots().await;
3018        self.observe_meta(&context.meta).await?;
3019        // Every host call is the model at work; a wait is the model blocked.
3020        let _turn = ToolTurn::enter(self, request.name.as_ref());
3021        match request.name.as_ref() {
3022            STATUS_TOOL => {
3023                let status = self.status().await;
3024                let value = serde_json::to_value(status)
3025                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
3026                Ok(CallToolResult::structured(value).into())
3027            }
3028            CONFIGURE_TOOL => {
3029                let args: ConfigureArgs = match request.arguments {
3030                    Some(map) => serde_json::from_value(Value::Object(map))
3031                        .map_err(|e| ErrorData::invalid_params(e.to_string(), None))?,
3032                    None => ConfigureArgs::default(),
3033                };
3034                match self.configure(args).await {
3035                    Ok(result) => {
3036                        let value = serde_json::to_value(result)
3037                            .map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
3038                        Ok(CallToolResult::structured(value).into())
3039                    }
3040                    // The model must read this one, so it is a tool error
3041                    // rather than a protocol error.
3042                    Err(e) => Ok(tool_error(format!("{e:#}")).into()),
3043                }
3044            }
3045            // Delivery has to mean something. The bus hands over references
3046            // and records nothing; this writes them to disk, fsyncs, and
3047            // only then tells the bus they are held. A crash in between
3048            // costs a redelivery, which is idempotent by design.
3049            "fetch_conversation_inbox" => {
3050                let result = self.forward(request, context.ct.clone()).await?;
3051                Ok(self.spool_and_confirm(result, context.ct).await.into())
3052            }
3053            // A message with no channel and no recipient goes to this
3054            // window's channel. The state was decorative until now: the
3055            // instructions and session_status promised a default the bus
3056            // never saw.
3057            // A long wait is one call for the model and many for the bus:
3058            // the bus answers within a minute (HTTP intermediaries), and a
3059            // host that re-asks every minute spends a model turn each time.
3060            "wait_for_updates" => Ok(self.long_wait(request, context.ct).await?.into()),
3061            // A resume rotates the secret this proxy sends; the proxy has to
3062            // be the one that keeps the new one (#214). The lifetime is the
3063            // window's, not the call's: see `as_proxied`.
3064            "resume_session" => {
3065                if request
3066                    .arguments
3067                    .as_ref()
3068                    .and_then(|a| a.get("ttl_seconds"))
3069                    .is_some_and(|v| !v.is_null())
3070                {
3071                    return Ok(tool_error(RESUME_TTL_REFUSED.to_owned()).into());
3072                }
3073                match self.resume_in_place().await {
3074                    Some(Ok(value)) => Ok(CallToolResult::structured(value).into()),
3075                    Some(Err(said)) => Ok(tool_error(said).into()),
3076                    None => Ok(self.forward(request, context.ct).await?.into()),
3077                }
3078            }
3079            "post_message" => {
3080                let mut request = request;
3081                if let Some(channel) = self.default_channel().await {
3082                    let args = request.arguments.get_or_insert_with(Default::default);
3083                    let addressed = args.contains_key("channel") || args.contains_key("to");
3084                    if !addressed {
3085                        args.insert("channel".into(), Value::String(channel));
3086                    }
3087                }
3088                Ok(self.forward(request, context.ct).await?.into())
3089            }
3090            _ => Ok(self.forward(request, context.ct).await?.into()),
3091        }
3092    }
3093}
3094
3095/// Run the proxy over stdio until the host closes the pipe.
3096pub async fn run(opts: ProxyOptions) -> anyhow::Result<()> {
3097    let proxy = Proxy::start(opts).await;
3098    let ct = CancellationToken::new();
3099    let keepalive = tokio::spawn(proxy.clone().keepalive(ct.child_token()));
3100
3101    let running = proxy
3102        .clone()
3103        .serve(rmcp::transport::stdio())
3104        .await
3105        .context("MCP initialize over stdio failed")?;
3106    let quit = running.waiting().await;
3107    tracing::debug!(?quit, "host closed the connection");
3108
3109    ct.cancel();
3110    let _ = keepalive.await;
3111    proxy.shutdown().await;
3112    Ok(())
3113}
3114
3115#[cfg(test)]
3116mod renewal_tests {
3117    #[test]
3118    fn renewal_lead_is_half_the_lifetime_unless_overridden_and_inside_it() {
3119        assert_eq!(super::renewal_lead_secs(24 * 3600, None), 12 * 3600);
3120        assert_eq!(super::renewal_lead_secs(60, None), 30);
3121        assert_eq!(super::renewal_lead_secs(60, Some(50)), 50);
3122        assert_eq!(
3123            super::renewal_lead_secs(60, Some(600)),
3124            59,
3125            "never past the lifetime"
3126        );
3127        assert_eq!(
3128            super::renewal_lead_secs(1, None),
3129            1,
3130            "a degenerate lifetime still yields a lead"
3131        );
3132    }
3133}
3134
3135#[cfg(test)]
3136mod unauthorized_tests {
3137    use rmcp::{
3138        RoleClient,
3139        model::{ErrorCode, ErrorData},
3140        transport::{
3141            DynamicTransportError, StreamableHttpClientTransport,
3142            streamable_http_client::{AuthRequiredError, StreamableHttpError},
3143        },
3144    };
3145
3146    use super::*;
3147
3148    fn transport(e: StreamableHttpError<reqwest::Error>) -> ServiceError {
3149        ServiceError::TransportSend(DynamicTransportError::new::<
3150            StreamableHttpClientTransport<reqwest::Client>,
3151            RoleClient,
3152        >(e))
3153    }
3154
3155    #[test]
3156    fn a_rejected_bearer_is_the_transport_saying_so() {
3157        let e = transport(StreamableHttpError::AuthRequired(AuthRequiredError::new(
3158            "Bearer".into(),
3159        )));
3160        assert!(unauthorized(&e));
3161    }
3162
3163    #[test]
3164    fn a_refusal_that_spells_401_is_still_a_refusal() {
3165        // The bus quoting a session label, a lease or an id that contains
3166        // "401" is talking to the model, not rejecting its credential.
3167        let e = ServiceError::McpError(ErrorData::invalid_request(
3168            "you do not hold the claim on 'api#1': it is held by joaquin (session \
3169             's-a7da401d8d70'), the lease expires in 401s",
3170            None,
3171        ));
3172        assert!(!unauthorized(&e));
3173        let e = ServiceError::McpError(ErrorData::internal_error("Auth required", None));
3174        assert!(
3175            !unauthorized(&e),
3176            "not even when it borrows the transport's words"
3177        );
3178    }
3179
3180    #[test]
3181    fn a_missing_tool_is_the_code_saying_so() {
3182        // rmcp 3.x, unknown tool.
3183        let e = ServiceError::McpError(ErrorData::invalid_params("tool not found", None));
3184        assert!(no_such_tool(&e));
3185        assert_eq!(verdict(&e), Some(Verdict::NoSuchTool));
3186        // A JSON-RPC layer without the method at all.
3187        let e = ServiceError::McpError(ErrorData::new(
3188            ErrorCode::METHOD_NOT_FOUND,
3189            "Method not found",
3190            None,
3191        ));
3192        assert!(no_such_tool(&e));
3193    }
3194
3195    #[test]
3196    fn a_refusal_that_spells_a_missing_method_is_still_a_refusal() {
3197        // The bus's conflict on a live label quotes the label, and a label
3198        // can hash to `s-32601…`. Same code as "tool not found".
3199        let e = ServiceError::McpError(ErrorData::invalid_params(
3200            "conflict: session 's-32601f03e877' is already registered and still live. \
3201             Holding the agent token does not make you that window: Method aside, \
3202             reconnect it with resume_session",
3203            None,
3204        ));
3205        assert!(!no_such_tool(&e));
3206        assert_eq!(verdict(&e), None);
3207        // The bus's own not-found errors share the code too.
3208        let e = ServiceError::McpError(ErrorData::invalid_params("not found: message 32601", None));
3209        assert!(!no_such_tool(&e));
3210        // Exactly rmcp's wording, not a prefix of it: a tool that exists
3211        // could open its refusal with the same three words.
3212        let e = ServiceError::McpError(ErrorData::invalid_params(
3213            "tool not found: the deploy tool named in `depends_on` does not exist",
3214            None,
3215        ));
3216        assert!(!no_such_tool(&e));
3217        assert!(!no_such_tool(&ServiceError::TransportClosed));
3218    }
3219
3220    #[test]
3221    fn a_verdict_survives_the_anyhow_chain() {
3222        let e = anyhow::Error::new(Verdict::NoSuchTool).context("register_session failed");
3223        assert_eq!(verdict_of(&e), Some(Verdict::NoSuchTool));
3224        let e = anyhow::anyhow!("register_session failed: tool not found -32601 Method");
3225        assert_eq!(verdict_of(&e), None, "words are not a verdict");
3226    }
3227
3228    #[test]
3229    fn an_http_refusal_is_read_for_its_shape() {
3230        let answered = |msg: &str| {
3231            transport(StreamableHttpError::UnexpectedServerResponse(
3232                msg.to_owned().into(),
3233            ))
3234        };
3235        // The bus's middleware body, at any status: its words, nothing ran.
3236        let e = answered(r#"HTTP 429 Too Many Requests: {"error":"rate limit exceeded"}"#);
3237        assert_eq!(
3238            refusal(&e),
3239            Some(Refusal {
3240                status: 429,
3241                said: Some("rate limit exceeded".into())
3242            })
3243        );
3244        assert!(remote_error_text(&e).contains("rate limit exceeded"));
3245        assert!(!unauthorized(&e));
3246        // A bare 4xx from something in front of the bus still ran nothing.
3247        let e = answered("HTTP 404 Not Found: <html>nope</html>");
3248        assert_eq!(
3249            refusal(&e),
3250            Some(Refusal {
3251                status: 404,
3252                said: None
3253            })
3254        );
3255        // A bare 5xx may be a gateway that gave up on a call that ran.
3256        assert_eq!(refusal(&answered("HTTP 504 Gateway Timeout: ")), None);
3257        assert_eq!(
3258            refusal(&answered("invalid www-authenticate header value")),
3259            None
3260        );
3261        assert_eq!(refusal(&ServiceError::TransportClosed), None);
3262    }
3263
3264    #[test]
3265    fn another_transport_failure_is_not_a_rejected_bearer() {
3266        let e = transport(StreamableHttpError::UnexpectedContentType(Some(
3267            "text/html; 401".into(),
3268        )));
3269        assert!(!unauthorized(&e));
3270        assert!(!unauthorized(&ServiceError::TransportClosed));
3271    }
3272}
3273
3274#[cfg(test)]
3275mod attention_tests {
3276    use super::{ATTENTION_REPORT_EVERY, Attention, AttentionReport};
3277    use std::time::{Duration, Instant};
3278
3279    fn ago(d: Duration) -> Instant {
3280        Instant::now() - d
3281    }
3282
3283    #[test]
3284    fn nothing_seen_reports_nothing_even_on_a_refresh() {
3285        let mut a = Attention::default();
3286        assert_eq!(a.due(true), None, "a keepalive is not news about the model");
3287    }
3288
3289    #[test]
3290    fn a_call_reports_attending_and_a_burst_is_throttled() {
3291        let call = Instant::now();
3292        let mut a = Attention {
3293            calls: 1,
3294            unreported_call: Some(call),
3295            ..Attention::default()
3296        };
3297        let r = a.due(false).expect("first call");
3298        assert_eq!(r.state, "attending");
3299        a.sent(r);
3300        a.unreported_call = Some(Instant::now());
3301        assert_eq!(a.due(false), None, "same state inside the window");
3302        a.reported = Some(("attending", ago(ATTENTION_REPORT_EVERY)));
3303        assert_eq!(a.due(false).map(|r| r.state), Some("attending"));
3304    }
3305
3306    #[test]
3307    fn a_keepalive_never_sends_a_stale_call() {
3308        // A throttled call, then the turn ended (a Stop hook said idle).
3309        // Minutes later the keepalive must not report it as current.
3310        let mut a = Attention {
3311            unreported_call: Some(ago(Duration::from_secs(120))),
3312            reported: Some(("attending", ago(Duration::from_secs(130)))),
3313            ..Attention::default()
3314        };
3315        assert_eq!(a.due(true), None);
3316        assert_eq!(a.unreported_call, None, "the stale call is dropped");
3317    }
3318
3319    #[test]
3320    fn a_failed_send_is_retried() {
3321        let mut a = Attention {
3322            calls: 1,
3323            unreported_call: Some(Instant::now()),
3324            ..Attention::default()
3325        };
3326        let r = a.due(false).expect("due");
3327        // Not committed: the bus refused it.
3328        assert_eq!(a.due(false), Some(r));
3329        a.sent(r);
3330        assert_eq!(a.due(false), None);
3331    }
3332
3333    #[test]
3334    fn a_wait_reports_waiting_and_a_refresh_keeps_it_fresh() {
3335        let mut a = Attention {
3336            calls: 1,
3337            waits: 1,
3338            unreported_call: Some(Instant::now()),
3339            ..Attention::default()
3340        };
3341        let r = a.due(false).expect("wait");
3342        assert_eq!(r.state, "waiting");
3343        a.sent(r);
3344        assert_eq!(a.due(false), None);
3345        assert_eq!(
3346            a.due(true),
3347            Some(AttentionReport {
3348                state: "waiting",
3349                call: None
3350            })
3351        );
3352        // Back from the wait: attending again at once.
3353        a.waits = 0;
3354        a.unreported_call = Some(Instant::now());
3355        assert_eq!(a.due(false).map(|r| r.state), Some("attending"));
3356    }
3357}