Skip to content

Add Maestro local A2A bridge#399

Open
haasonsaas wants to merge 31 commits into
mainfrom
codex/maestro-real-a2a-20260515
Open

Add Maestro local A2A bridge#399
haasonsaas wants to merge 31 commits into
mainfrom
codex/maestro-real-a2a-20260515

Conversation

@haasonsaas
Copy link
Copy Markdown
Contributor

@haasonsaas haasonsaas commented May 15, 2026

Summary

  • expose the Rust control-plane as an A2A HTTP+JSON agent with Agent Card discovery, message send, task lookup/list, and cancel routes
  • route incoming A2A messages through the native Rust TUI agent runner, including task history/artifacts/metadata and bounded tool approval handling
  • add a TS smoke harness that discovers the Rust A2A Agent Card, sends an A2A message with the existing TS client, and fetches the completed task

Source of truth

  • evalops/maestro-internal#1927

Test Plan

  • cd packages/control-plane-rs && cargo check --bin maestro-control-plane
  • cd packages/control-plane-rs && cargo test --bin maestro-control-plane a2a
  • bun run smoke:a2a-local
  • commit hook: guardian + bunx biome check . && bun run lint:evals + build/binary compile

Notes

  • The smoke uses MAESTRO_A2A_FAKE_RESPONSE so CI/local checks prove the A2A wire path without spending model tokens. Without that env var, /message:send invokes the native Maestro agent runner.

@cursor
Copy link
Copy Markdown

cursor Bot commented May 15, 2026

PR Summary

Medium Risk
Adds new public HTTP endpoints and in-memory task state for A2A message execution/cancellation, plus expands CSRF/CORS handling; mistakes could impact local auth boundaries or allow unintended request access.

Overview
Exposes the Rust control-plane as a local A2A HTTP+JSON agent, adding /.well-known/agent-card.json, POST /message:send, GET /tasks + GET /tasks/:id, and POST /tasks/:id:cancel, with in-memory task/history/artifact storage and optional asynchronous completion via returnImmediately.

Updates request protection to cover these new A2A routes (CSRF applicability, per-subject task visibility, cancel signaling via watch), and expands CORS allowed headers to include A2A + tracing + platform identity headers.

Adds a new smoke test script smoke:a2a-local that spins up the Rust server with MAESTRO_A2A_FAKE_RESPONSE, discovers the agent card via the existing TS A2A client, sends a message, and verifies the completed task/artifact.

Reviewed by Cursor Bugbot for commit 6794aff. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions
Copy link
Copy Markdown
Contributor

This PR changes mirrored Maestro source files in the public repo, but it does not link the matching private source-of-truth PR.

Add one of these to the PR body, then re-run the check:

  • https://github.com/evalops/maestro-internal/pull/<number>
  • evalops/maestro-internal#<number>
  • maestro-internal#<number>

Mirrored files touched:

  • package.json
  • packages/control-plane-rs/src/http.rs
  • packages/control-plane-rs/src/main.rs
  • scripts/smoke-maestro-a2a-local.ts

@socket-security
Copy link
Copy Markdown

socket-security Bot commented May 15, 2026

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: npm @daytonaio/sdk is 100.0% likely obfuscated

Confidence: 1.00

Location: Package overview

From: package.jsonnpm/@daytonaio/sdk@0.155.0

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/@daytonaio/sdk@0.155.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm ioredis is 96.0% likely obfuscated

Confidence: 0.96

Location: Package overview

From: package.jsonnpm/ioredis@5.10.1

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/ioredis@5.10.1. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 53624feb17

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/control-plane-rs/src/main.rs Outdated
Comment thread packages/control-plane-rs/src/main.rs Outdated
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Background task overwrites canceled task status unconditionally
    • A2A task completion now skips writing back to the task map when the stored task is already canceled, and a regression test covers the race.
Preview (8ee0590c6d)
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -104,6 +104,7 @@
     "smoke": "node scripts/smoke-cli.js",
     "smoke:local-e2e": "node scripts/smoke-cli.js",
     "smoke:event-bus": "tsx scripts/smoke-maestro-event-bus.ts",
+    "smoke:a2a-local": "tsx scripts/smoke-maestro-a2a-local.ts",
     "smoke:headless": "node scripts/smoke-headless.js",
     "headless:responsiveness": "node scripts/headless-responsiveness-harness.js",
     "smoke:pack": "node scripts/smoke-packed-cli.js",

diff --git a/packages/control-plane-rs/src/http.rs b/packages/control-plane-rs/src/http.rs
--- a/packages/control-plane-rs/src/http.rs
+++ b/packages/control-plane-rs/src/http.rs
@@ -11,7 +11,7 @@
 
 const MAX_HEADER_BYTES: usize = 64 * 1024;
 pub(crate) const MAX_JSON_BODY_BYTES: usize = 32 * 1024 * 1024;
-const CORS_ALLOW_HEADERS: &str = "authorization,content-type,x-composer-artifact-access,x-composer-api-key,x-composer-approval-mode,x-composer-client,x-composer-client-tools,x-composer-csrf,x-composer-agent-id,x-composer-slim-events,x-composer-workspace,x-composer-workspace-id,x-maestro-artifact-access,x-maestro-api-key,x-maestro-approval-mode,x-maestro-agent-id,x-maestro-client,x-maestro-client-tools,x-maestro-csrf,x-maestro-slim-events,x-maestro-workspace,x-maestro-workspace-id,x-csrf-token,x-xsrf-token";
+const CORS_ALLOW_HEADERS: &str = "authorization,content-type,a2a-version,a2a-extensions,traceparent,tracestate,x-evalops-agent-id,x-evalops-actor-id,x-evalops-session-id,x-evalops-workspace-id,x-composer-artifact-access,x-composer-api-key,x-composer-approval-mode,x-composer-client,x-composer-client-tools,x-composer-csrf,x-composer-agent-id,x-composer-slim-events,x-composer-workspace,x-composer-workspace-id,x-maestro-artifact-access,x-maestro-api-key,x-maestro-approval-mode,x-maestro-agent-id,x-maestro-client,x-maestro-client-tools,x-maestro-csrf,x-maestro-slim-events,x-maestro-workspace,x-maestro-workspace-id,x-csrf-token,x-xsrf-token";
 
 #[derive(Debug)]
 pub(crate) struct RequestHead {

diff --git a/packages/control-plane-rs/src/main.rs b/packages/control-plane-rs/src/main.rs
--- a/packages/control-plane-rs/src/main.rs
+++ b/packages/control-plane-rs/src/main.rs
@@ -49,6 +49,8 @@
 const DEFAULT_EXTRACT_MAX_CHARS: usize = 200_000;
 const MAX_EXTRACT_INPUT_BYTES: usize = 50 * 1024 * 1024;
 const MAX_PROJECT_ONBOARDING_IMPRESSIONS: u8 = 4;
+const A2A_PROTOCOL_VERSION: &str = "1.0";
+const A2A_DEFAULT_TURN_TIMEOUT_MS: u64 = 180_000;
 static ATTACHMENT_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
 static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
 type PendingToolResponseSender = mpsc::UnboundedSender<(String, bool, Option<ToolResult>)>;
@@ -151,6 +153,7 @@
     shared_sessions: Arc<Mutex<HashMap<String, SharedSessionGrant>>>,
     approval_modes: Arc<Mutex<HashMap<String, String>>>,
     pending_tool_responses: Arc<Mutex<HashMap<String, PendingToolResponseSender>>>,
+    a2a_tasks: Arc<Mutex<HashMap<String, Value>>>,
 }
 
 #[derive(Clone, Debug, Default, Serialize)]
@@ -295,6 +298,7 @@
         shared_sessions: Arc::new(Mutex::new(shared_sessions)),
         approval_modes: Arc::new(Mutex::new(HashMap::new())),
         pending_tool_responses: Arc::new(Mutex::new(HashMap::new())),
+        a2a_tasks: Arc::new(Mutex::new(HashMap::new())),
     };
 
     loop {
@@ -329,6 +333,16 @@
             return handle_chat_endpoint(stream, initial, head, state).await;
         }
 
+        if is_a2a_endpoint(&head) {
+            let response = handle_a2a_endpoint(&mut stream, &mut initial, head, &state).await;
+            stream
+                .write_all(&response)
+                .await
+                .map_err(|error| error.to_string())?;
+            let _ = stream.shutdown().await;
+            return Ok(());
+        }
+
         if is_local_endpoint(&head) {
             let response = handle_local_endpoint(&mut stream, &mut initial, head, &state).await;
             stream
@@ -508,6 +522,642 @@
     Some(request_id)
 }
 
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct A2APartBody {
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    text: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    url: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    data: Option<Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    metadata: Option<Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    filename: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    media_type: Option<String>,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct A2AMessageBody {
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    message_id: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    context_id: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    task_id: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    role: Option<String>,
+    #[serde(default)]
+    parts: Vec<A2APartBody>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    metadata: Option<Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    extensions: Option<Vec<String>>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    reference_task_ids: Option<Vec<String>>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct A2ASendMessageRequest {
+    message: A2AMessageBody,
+    #[serde(default)]
+    configuration: Option<Value>,
+    #[serde(default)]
+    metadata: Option<Value>,
+}
+
+#[derive(Debug, Default)]
+struct A2ATurnOutput {
+    assistant_text: String,
+    thinking_text: String,
+    usage: Option<TokenUsage>,
+    tools: Vec<Value>,
+}
+
+fn is_a2a_endpoint(head: &RequestHead) -> bool {
+    if head.method == "OPTIONS" {
+        return head.path == "/.well-known/agent-card.json"
+            || head.path == "/message:send"
+            || head.path == "/tasks"
+            || head.path.starts_with("/tasks/");
+    }
+    matches!(
+        (head.method.as_str(), head.path.as_str()),
+        ("GET", "/.well-known/agent-card.json") | ("POST", "/message:send") | ("GET", "/tasks")
+    ) || (head.method == "GET" && a2a_task_id_from_get_path(&head.path).is_some())
+        || (head.method == "POST" && a2a_task_id_from_cancel_path(&head.path).is_some())
+}
+
+fn a2a_task_id_from_get_path(path: &str) -> Option<&str> {
+    let id = path.strip_prefix("/tasks/")?;
+    (!id.is_empty() && !id.contains('/') && !id.contains(':')).then_some(id)
+}
+
+fn a2a_task_id_from_cancel_path(path: &str) -> Option<&str> {
+    let id = path.strip_prefix("/tasks/")?.strip_suffix(":cancel")?;
+    (!id.is_empty() && !id.contains('/') && !id.contains(':')).then_some(id)
+}
+
+async fn handle_a2a_endpoint(
+    stream: &mut TcpStream,
+    initial: &mut Vec<u8>,
+    head: RequestHead,
+    state: &AppState,
+) -> Vec<u8> {
+    if head.method == "OPTIONS" {
+        return response(204, "text/plain; charset=utf-8", &[]);
+    }
+
+    if head.method == "GET" && head.path == "/.well-known/agent-card.json" {
+        return json_response(200, &a2a_agent_card(&head, &state.config));
+    }
+
+    if let Err(response) = authorize(&head, &state.config) {
+        return response;
+    }
+
+    if head.method == "GET" && head.path == "/tasks" {
+        let tasks = state
+            .a2a_tasks
+            .lock()
+            .await
+            .values()
+            .cloned()
+            .collect::<Vec<_>>();
+        return json_response(200, &serde_json::json!({ "tasks": tasks }));
+    }
+
+    if head.method == "GET" {
+        if let Some(task_id) = a2a_task_id_from_get_path(&head.path) {
+            let tasks = state.a2a_tasks.lock().await;
+            return tasks.get(task_id).map_or_else(
+                || a2a_error_response(404, "TASK_NOT_FOUND", "A2A task not found"),
+                |task| json_response(200, task),
+            );
+        }
+    }
+
+    if head.method == "POST" {
+        if let Some(task_id) = a2a_task_id_from_cancel_path(&head.path) {
+            let mut tasks = state.a2a_tasks.lock().await;
+            let Some(task) = tasks.get_mut(task_id) else {
+                return a2a_error_response(404, "TASK_NOT_FOUND", "A2A task not found");
+            };
+            let context_id = task
+                .get("contextId")
+                .and_then(Value::as_str)
+                .unwrap_or("a2a")
+                .to_string();
+            task["status"] = serde_json::json!({
+                "state": "TASK_STATE_CANCELED",
+                "message": a2a_agent_message(&context_id, "Task canceled"),
+                "timestamp": now_rfc3339()
+            });
+            return json_response(200, task);
+        }
+    }
+
+    if head.method == "POST" && head.path == "/message:send" {
+        return handle_a2a_message_send(stream, initial, &head, state).await;
+    }
+
+    a2a_error_response(404, "NOT_FOUND", "A2A endpoint not found")
+}
+
+async fn handle_a2a_message_send(
+    stream: &mut TcpStream,
+    initial: &mut Vec<u8>,
+    head: &RequestHead,
+    state: &AppState,
+) -> Vec<u8> {
+    let body = match read_request_body(stream, initial, head).await {
+        Ok(body) => body,
+        Err(error) => return a2a_error_response(400, "INVALID_REQUEST", &error),
+    };
+    let request: A2ASendMessageRequest = match serde_json::from_slice(&body) {
+        Ok(request) => request,
+        Err(error) => {
+            return a2a_error_response(
+                400,
+                "INVALID_REQUEST",
+                &format!("invalid A2A message request: {error}"),
+            );
+        }
+    };
+
+    let Some(prompt) = a2a_message_text(&request.message) else {
+        return a2a_error_response(
+            400,
+            "INVALID_REQUEST",
+            "A2A message must contain at least one text part",
+        );
+    };
+
+    let context_id = a2a_context_id(&request, head);
+    let task_id = generate_a2a_id("maestro-task");
+    let user_message = a2a_user_message_value(&request.message, &context_id);
+
+    let metadata = a2a_task_metadata(head, &request);
+    if a2a_return_immediately(&request) {
+        let accepted_message = a2a_agent_message(&context_id, "Maestro accepted the A2A task.");
+        let task = a2a_task_value(
+            &task_id,
+            &context_id,
+            "TASK_STATE_WORKING",
+            accepted_message.clone(),
+            vec![user_message.clone(), accepted_message],
+            Vec::new(),
+            metadata.clone(),
+        );
+        state
+            .a2a_tasks
+            .lock()
+            .await
+            .insert(task_id.clone(), task.clone());
+        let state = state.clone();
+        tokio::spawn(async move {
+            let _ = complete_a2a_task(&state, prompt, task_id, context_id, user_message, metadata)
+                .await;
+        });
+        return json_response(200, &serde_json::json!({ "task": task }));
+    }
+
+    let task = complete_a2a_task(state, prompt, task_id, context_id, user_message, metadata).await;
+    json_response(200, &serde_json::json!({ "task": task }))
+}
+
+async fn complete_a2a_task(
+    state: &AppState,
+    prompt: String,
+    task_id: String,
+    context_id: String,
+    user_message: Value,
+    mut metadata: Value,
+) -> Value {
+    let turn = match run_a2a_native_turn(state, prompt).await {
+        Ok(turn) => turn,
+        Err(error) => {
+            let message = a2a_agent_message(&context_id, &error);
+            let task = a2a_task_value(
+                &task_id,
+                &context_id,
+                "TASK_STATE_FAILED",
+                message.clone(),
+                vec![user_message, message],
+                Vec::new(),
+                metadata,
+            );
+            insert_a2a_task_if_not_canceled(state, &task_id, task.clone()).await;
+            return task;
+        }
+    };
+
+    let assistant_text = if turn.assistant_text.trim().is_empty() {
+        "Maestro completed the A2A task without a text response.".to_string()
+    } else {
+        turn.assistant_text
+    };
+    let agent_message = a2a_agent_message(&context_id, &assistant_text);
+    if !turn.thinking_text.trim().is_empty() {
+        metadata["thinking"] = Value::String(turn.thinking_text);
+    }
+    if !turn.tools.is_empty() {
+        metadata["tools"] = Value::Array(turn.tools);
+    }
+    if let Some(usage) = turn.usage {
+        metadata["usage"] = serde_json::json!({
+            "input": usage.input_tokens,
+            "output": usage.output_tokens,
+            "cacheRead": usage.cache_read_tokens,
+            "cacheWrite": usage.cache_write_tokens,
+            "cost": usage.cost.unwrap_or(0.0)
+        });
+    }
+    let task = a2a_task_value(
+        &task_id,
+        &context_id,
+        "TASK_STATE_COMPLETED",
+        agent_message.clone(),
+        vec![user_message, agent_message],
+        vec![serde_json::json!({
+            "artifactId": format!("{task_id}-assistant-response"),
+            "name": "assistant-response",
+            "parts": [{ "text": assistant_text, "mediaType": "text/plain" }]
+        })],
+        metadata,
+    );
+    insert_a2a_task_if_not_canceled(state, &task_id, task.clone()).await;
+    task
+}
+
+async fn insert_a2a_task_if_not_canceled(state: &AppState, task_id: &str, task: Value) {
+    let mut tasks = state.a2a_tasks.lock().await;
+    let is_canceled = tasks
+        .get(task_id)
+        .and_then(|existing| existing.get("status"))
+        .and_then(|status| status.get("state"))
+        .and_then(Value::as_str)
+        == Some("TASK_STATE_CANCELED");
+    if !is_canceled {
+        tasks.insert(task_id.to_string(), task);
+    }
+}
+
+fn a2a_agent_card(head: &RequestHead, config: &Config) -> Value {
+    let base_url = a2a_public_base_url(head, config);
+    serde_json::json!({
+        "protocolVersion": A2A_PROTOCOL_VERSION,
+        "name": trimmed_env("MAESTRO_A2A_AGENT_NAME")
+            .unwrap_or_else(|| "Maestro Desktop Agent".to_string()),
+        "description": "Local Maestro Rust/TS TUI agent endpoint for A2A task delegation.",
+        "url": base_url,
+        "preferredTransport": "HTTP+JSON",
+        "supportedInterfaces": [{
+            "url": base_url,
+            "protocolBinding": "HTTP+JSON",
+            "protocolVersion": A2A_PROTOCOL_VERSION
+        }],
+        "provider": {
+            "organization": "EvalOps",
+            "url": "https://evalops.com"
+        },
+        "version": env!("CARGO_PKG_VERSION"),
+        "capabilities": {
+            "streaming": false,
+            "pushNotifications": false,
+            "extendedAgentCard": false
+        },
+        "defaultInputModes": ["text/plain"],
+        "defaultOutputModes": ["text/plain", "application/json"],
+        "skills": [{
+            "id": "maestro-tui-turn",
+            "name": "Maestro TUI turn",
+            "description": "Run a prompt through the local Maestro native TUI agent runner.",
+            "tags": ["maestro", "tui", "codex", "a2a"],
+            "examples": [
+                "Review the current workspace and summarize the next highest leverage action."
+            ],
+            "inputModes": ["text/plain"],
+            "outputModes": ["text/plain", "application/json"]
+        }]
+    })
+}
+
+fn a2a_public_base_url(head: &RequestHead, config: &Config) -> String {
+    if let Some(url) =
+        trimmed_env("MAESTRO_A2A_PUBLIC_URL").or_else(|| trimmed_env("MAESTRO_CONTROL_PUBLIC_URL"))
+    {
+        return url.trim_end_matches('/').to_string();
+    }
+    let proto = head
+        .headers
+        .get("x-forwarded-proto")
+        .and_then(|value| value.split(',').next())
+        .map(str::trim)
+        .filter(|value| !value.is_empty())
+        .unwrap_or("http");
+    let host = head
+        .headers
+        .get("host")
+        .map(String::as_str)
+        .filter(|host| !host.trim().is_empty())
+        .map(str::trim)
+        .map(str::to_string)
+        .unwrap_or_else(|| {
+            let host = if config.listen_host == "0.0.0.0" || config.listen_host == "::" {
+                "127.0.0.1"
+            } else {
+                config.listen_host.as_str()
+            };
+            format!("{host}:{}", config.listen_port)
+        });
+    format!("{proto}://{host}")
+}
+
+fn a2a_message_text(message: &A2AMessageBody) -> Option<String> {
+    let text = message
+        .parts
+        .iter()
+        .filter_map(|part| part.text.as_deref())
+        .map(str::trim)
+        .filter(|part| !part.is_empty())
+        .collect::<Vec<_>>()
+        .join("\n\n");
+    (!text.is_empty()).then_some(text)
+}
+
+fn a2a_context_id(request: &A2ASendMessageRequest, head: &RequestHead) -> String {
+    request
+        .message
+        .context_id
+        .as_deref()
+        .or_else(|| {
+            request
+                .message
+                .metadata
+                .as_ref()
+                .and_then(|metadata| metadata.get("sessionId").and_then(Value::as_str))
+        })
+        .or_else(|| head.headers.get("x-evalops-session-id").map(String::as_str))
+        .or_else(|| head.headers.get("x-maestro-session-id").map(String::as_str))
+        .map(str::trim)
+        .filter(|value| !value.is_empty())
+        .map(str::to_string)
+        .unwrap_or_else(|| generate_a2a_id("maestro-context"))
+}
+
+fn a2a_user_message_value(message: &A2AMessageBody, context_id: &str) -> Value {
+    let mut value = serde_json::to_value(message).unwrap_or_else(|_| serde_json::json!({}));
+    if let Value::Object(object) = &mut value {
+        object
+            .entry("messageId")
+            .or_insert_with(|| Value::String(generate_a2a_id("maestro-message")));
+        object
+            .entry("contextId")
+            .or_insert_with(|| Value::String(context_id.to_string()));
+        object
+            .entry("role")
+            .or_insert_with(|| Value::String("ROLE_USER".to_string()));
+    }
+    value
+}
+
+fn a2a_agent_message(context_id: &str, text: &str) -> Value {
+    serde_json::json!({
+        "messageId": generate_a2a_id("maestro-message"),
+        "contextId": context_id,
+        "role": "ROLE_AGENT",
+        "parts": [{ "text": text, "mediaType": "text/plain" }],
+        "metadata": {
+            "runtime": "maestro-rust-control-plane",
+            "surface": "rust-tui"
+        }
+    })
+}
+
+fn a2a_task_value(
+    task_id: &str,
+    context_id: &str,
+    state: &str,
+    status_message: Value,
+    history: Vec<Value>,
+    artifacts: Vec<Value>,
+    metadata: Value,
+) -> Value {
+    serde_json::json!({
+        "id": task_id,
+        "contextId": context_id,
+        "status": {
+            "state": state,
+            "message": status_message,
+            "timestamp": now_rfc3339()
+        },
+        "history": history,
+        "artifacts": artifacts,
+        "metadata": metadata
+    })
+}
+
+fn a2a_task_metadata(head: &RequestHead, request: &A2ASendMessageRequest) -> Value {
+    let mut metadata = Map::new();
+    metadata.insert(
+        "runtime".to_string(),
+        Value::String("maestro-rust-control-plane".to_string()),
+    );
+    metadata.insert("surface".to_string(), Value::String("rust-tui".to_string()));
+    metadata.insert(
+        "a2aProtocolVersion".to_string(),
+        Value::String(A2A_PROTOCOL_VERSION.to_string()),
+    );
+    for (field, header) in [
+        ("workspaceId", "x-evalops-workspace-id"),
+        ("agentId", "x-evalops-agent-id"),
+        ("sessionId", "x-evalops-session-id"),
+        ("actorId", "x-evalops-actor-id"),
+        ("traceparent", "traceparent"),
+        ("tracestate", "tracestate"),
+    ] {
+        if let Some(value) = head.headers.get(header).map(String::as_str) {
+            if !value.trim().is_empty() {
+                metadata.insert(field.to_string(), Value::String(value.trim().to_string()));
+            }
+        }
+    }
+    if let Some(Value::Object(request_metadata)) = request.metadata.as_ref() {
+        for (key, value) in request_metadata {
+            metadata.entry(key.clone()).or_insert_with(|| value.clone());
+        }
+    }
+    if let Some(configuration) = request.configuration.as_ref() {
+        metadata
+            .entry("configuration".to_string())
+            .or_insert_with(|| configuration.clone());
+    }
+    if let Some(Value::Object(message_metadata)) = request.message.metadata.as_ref() {
+        for (key, value) in message_metadata {
+            metadata.entry(key.clone()).or_insert_with(|| value.clone());
+        }
+    }
+    Value::Object(metadata)
+}
+
+fn a2a_return_immediately(request: &A2ASendMessageRequest) -> bool {
+    request
+        .configuration
+        .as_ref()
+        .and_then(|configuration| configuration.get("returnImmediately"))
+        .and_then(Value::as_bool)
+        .unwrap_or(false)
+}
+
+async fn run_a2a_native_turn(state: &AppState, prompt: String) -> Result<A2ATurnOutput, String> {
+    if let Some(response) = trimmed_env("MAESTRO_A2A_FAKE_RESPONSE") {
+        return Ok(A2ATurnOutput {
+            assistant_text: response,
+            ..Default::default()
+        });
+    }
+
+    let model = if let Some(model) = trimmed_env("MAESTRO_A2A_MODEL") {
+        model
+    } else {
+        let selected = state.selected_model.lock().await;
+        format!("{}/{}", selected.provider, selected.id)
+    };
+    let config = NativeAgentConfig {
+        model,
+        cwd: state.config.cwd.to_string_lossy().to_string(),
+        system_prompt: Some(
+            trimmed_env("MAESTRO_A2A_SYSTEM_PROMPT").unwrap_or_else(|| {
+                "You are the local Maestro Desktop A2A agent. Complete delegated work from peer agents clearly and concisely.".to_string()
+            }),
+        ),
+        thinking_enabled: truthy_env("MAESTRO_A2A_THINKING"),
+        thinking_budget: env::var("MAESTRO_A2A_THINKING_BUDGET")
+            .ok()
+            .and_then(|value| value.parse().ok())
+            .unwrap_or(10_000),
+        ..NativeAgentConfig::default()
+    };
+    let (agent, mut events) = NativeAgent::new(config).map_err(|error| error.to_string())?;
+    agent
+        .prompt(prompt, Vec::new())
+        .await
+        .map_err(|error| error.to_string())?;
+
+    let timeout = Duration::from_millis(env_u64(
+        "MAESTRO_A2A_TURN_TIMEOUT_MS",
+        A2A_DEFAULT_TURN_TIMEOUT_MS,
+    ));
+    let approval_mode = trimmed_env("MAESTRO_A2A_TOOL_APPROVAL")
+        .unwrap_or_else(|| "fail".to_string())
+        .to_ascii_lowercase();
+    let auto_approve_tools = matches!(approval_mode.as_str(), "auto" | "approve" | "approved");
+    let mut output = A2ATurnOutput::default();
+    let mut last_error: Option<String> = None;
+    let mut response_ended = false;
+
+    loop {
+        let event = match tokio::time::timeout(timeout, events.recv()).await {
+            Ok(Some(event)) => event,
+            Ok(None) => break,
+            Err(_) => {
+                agent.cancel();
+                return Err("A2A native TUI turn timed out".to_string());
+            }
+        };
+        match event {
+            FromAgent::ResponseChunk {
+                content,
+                is_thinking,
+                ..
+            } => {
+                if is_thinking {
+                    output.thinking_text.push_str(&content);
+                } else {
+                    output.assistant_text.push_str(&content);
+                }
+            }
+            FromAgent::ResponseEnd { usage, .. } => {
+                output.usage = usage;
+                response_ended = true;
+                break;
+            }
+            FromAgent::ToolCall {
+                call_id,
+                tool,
+                args,
+                requires_approval,
+            } => {
+                record_tool_call_metadata(&mut output.tools, &call_id, &tool, args);
+                if requires_approval {
+                    let _ = agent.tool_response_sender().send((
+                        call_id.clone(),
+                        auto_approve_tools,
+                        None,
+                    ));
+                    if !auto_approve_tools {
+                        finish_tool_metadata(&mut output.tools, &call_id, false);
+                    }
+                }
+            }
+            FromAgent::ToolEnd {
+                call_id, success, ..
+            } => {
+                finish_tool_metadata(&mut output.tools, &call_id, success);
+            }
+            FromAgent::HookBlocked {
+                call_id,
+                tool,
+                reason,
+            } => {
+                if !output
+                    .tools
+                    .iter()
+                    .any(|entry| entry.get("id").and_then(Value::as_str) == Some(&call_id))
+                {
+                    record_tool_call_metadata(&mut output.tools, &call_id, &tool, Value::Null);
+                }
+                finish_tool_metadata(&mut output.tools, &call_id, false);
+                last_error = Some(reason);
+            }
+            FromAgent::Error { message, fatal } => {
+                last_error = Some(message);
+                if fatal {
+                    break;
+                }
+            }
+            _ => {}
+        }
+    }
+
+    if response_ended {
+        Ok(output)
+    } else {
+        Err(last_error
+            .unwrap_or_else(|| "A2A native TUI turn ended before response_end".to_string()))
+    }
+}
+
+fn generate_a2a_id(prefix: &str) -> String {
+    let mut bytes = [0_u8; 16];
+    if getrandom::fill(&mut bytes).is_ok() {
+        return format!("{prefix}-{}", URL_SAFE_NO_PAD.encode(bytes));
+    }
+    format!("{prefix}-{}-{}", now_millis(), process::id())
+}
+
+fn a2a_error_response(status: u16, code: &str, message: &str) -> Vec<u8> {
+    json_response(
+        status,
+        &serde_json::json!({ "error": { "code": code, "message": message } }),
+    )
+}
+
 async fn handle_local_endpoint(
     stream: &mut TcpStream,
     initial: &mut Vec<u8>,
@@ -5655,6 +6305,13 @@
         .unwrap_or(default)
 }
 
+fn env_u64(name: &str, default: u64) -> u64 {
+    env::var(name)
+        .ok()
+        .and_then(|value| value.parse::<u64>().ok())
+        .unwrap_or(default)
+}
+
 fn trimmed_env(name: &str) -> Option<String> {
     env::var(name)
         .ok()
@@ -5779,6 +6436,7 @@
             shared_sessions: Arc::new(Mutex::new(HashMap::new())),
             approval_modes: Arc::new(Mutex::new(HashMap::new())),
             pending_tool_responses: Arc::new(Mutex::new(HashMap::new())),
+            a2a_tasks: Arc::new(Mutex::new(HashMap::new())),
         }
     }
 
@@ -6028,6 +6686,146 @@
     }
 
     #[test]
+    fn detects_a2a_control_plane_routes() {
+        for request in [
+            "GET /.well-known/agent-card.json HTTP/1.1\r\nHost: localhost\r\n\r\n",
+            "POST /message:send HTTP/1.1\r\nHost: localhost\r\n\r\n",
+            "GET /tasks/maestro-task-1 HTTP/1.1\r\nHost: localhost\r\n\r\n",
+            "GET /tasks HTTP/1.1\r\nHost: localhost\r\n\r\n",
+            "POST /tasks/maestro-task-1:cancel HTTP/1.1\r\nHost: localhost\r\n\r\n",
+            "OPTIONS /message:send HTTP/1.1\r\nHost: localhost\r\n\r\n",
+        ] {
+            let head = parse_request_head(request.as_bytes()).expect("request should parse");
+            assert!(is_a2a_endpoint(&head), "{request} should be A2A");
+        }
+    }
+
+    #[test]
+    fn a2a_agent_card_advertises_http_json_interface() {
+        let head = parse_request_head(
+            b"GET /.well-known/agent-card.json HTTP/1.1\r\nHost: mini.local:8080\r\n\r\n",
+        )
+        .expect("request should parse");
+        let card = a2a_agent_card(&head, &auth_test_config());
+
+        assert_eq!(card["protocolVersion"], A2A_PROTOCOL_VERSION);
+        assert_eq!(card["url"], "http://mini.local:8080");
+        assert_eq!(
+            card["supportedInterfaces"][0]["protocolBinding"],
+            "HTTP+JSON"
+        );
+        assert_eq!(card["skills"][0]["id"], "maestro-tui-turn");
+    }
+
+    #[test]
+    fn a2a_send_message_honors_return_immediately_configuration() {
+        let request = A2ASendMessageRequest {
+            message: A2AMessageBody {
+                message_id: Some("msg-1".to_string()),
+                context_id: Some("ctx-1".to_string()),
+                task_id: None,
+                role: Some("ROLE_USER".to_string()),
+                parts: vec![A2APartBody {
+                    text: Some("hello".to_string()),
+                    url: None,
+                    data: None,
+                    metadata: None,
+                    filename: None,
+                    media_type: Some("text/plain".to_string()),
+                }],
+                metadata: None,
+                extensions: None,
+                reference_task_ids: None,
+            },
+            configuration: Some(serde_json::json!({ "returnImmediately": true })),
+            metadata: None,
+        };
+
+        assert!(a2a_return_immediately(&request));
+    }
+
+    #[tokio::test(flavor = "current_thread")]
+    async fn a2a_message_send_runs_fake_turn_and_records_task() {
+        let _guard = ENV_LOCK.lock().expect("env lock should not be poisoned");
... diff truncated: showing 800 of 1060 lines

You can send follow-ups to the cloud agent here.

Comment thread packages/control-plane-rs/src/main.rs Outdated
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8ee0590c6d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/control-plane-rs/src/http.rs Outdated
Comment thread packages/control-plane-rs/src/main.rs Outdated
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Turn timeout resets per event, not total
    • I replaced the per-receive timeout with one pinned turn-wide sleep so the configured deadline now bounds the entire A2A turn.
Preview (fae5207f8f)
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -104,6 +104,7 @@
     "smoke": "node scripts/smoke-cli.js",
     "smoke:local-e2e": "node scripts/smoke-cli.js",
     "smoke:event-bus": "tsx scripts/smoke-maestro-event-bus.ts",
+    "smoke:a2a-local": "tsx scripts/smoke-maestro-a2a-local.ts",
     "smoke:headless": "node scripts/smoke-headless.js",
     "headless:responsiveness": "node scripts/headless-responsiveness-harness.js",
     "smoke:pack": "node scripts/smoke-packed-cli.js",

diff --git a/packages/control-plane-rs/src/http.rs b/packages/control-plane-rs/src/http.rs
--- a/packages/control-plane-rs/src/http.rs
+++ b/packages/control-plane-rs/src/http.rs
@@ -11,7 +11,7 @@
 
 const MAX_HEADER_BYTES: usize = 64 * 1024;
 pub(crate) const MAX_JSON_BODY_BYTES: usize = 32 * 1024 * 1024;
-const CORS_ALLOW_HEADERS: &str = "authorization,content-type,x-composer-artifact-access,x-composer-api-key,x-composer-approval-mode,x-composer-client,x-composer-client-tools,x-composer-csrf,x-composer-agent-id,x-composer-slim-events,x-composer-workspace,x-composer-workspace-id,x-maestro-artifact-access,x-maestro-api-key,x-maestro-approval-mode,x-maestro-agent-id,x-maestro-client,x-maestro-client-tools,x-maestro-csrf,x-maestro-slim-events,x-maestro-workspace,x-maestro-workspace-id,x-csrf-token,x-xsrf-token";
+const CORS_ALLOW_HEADERS: &str = "authorization,content-type,a2a-version,a2a-extensions,traceparent,tracestate,x-organization-id,x-evalops-agent-id,x-evalops-actor-id,x-evalops-session-id,x-evalops-workspace-id,x-composer-artifact-access,x-composer-api-key,x-composer-approval-mode,x-composer-client,x-composer-client-tools,x-composer-csrf,x-composer-agent-id,x-composer-slim-events,x-composer-workspace,x-composer-workspace-id,x-maestro-artifact-access,x-maestro-api-key,x-maestro-approval-mode,x-maestro-agent-id,x-maestro-client,x-maestro-client-tools,x-maestro-csrf,x-maestro-slim-events,x-maestro-workspace,x-maestro-workspace-id,x-csrf-token,x-xsrf-token";
 
 #[derive(Debug)]
 pub(crate) struct RequestHead {

diff --git a/packages/control-plane-rs/src/main.rs b/packages/control-plane-rs/src/main.rs
--- a/packages/control-plane-rs/src/main.rs
+++ b/packages/control-plane-rs/src/main.rs
@@ -17,7 +17,7 @@
 use tokio::io::{AsyncReadExt, AsyncWriteExt};
 use tokio::net::{TcpListener, TcpStream};
 use tokio::process::Command;
-use tokio::sync::{mpsc, Mutex};
+use tokio::sync::{mpsc, watch, Mutex};
 
 mod auth;
 mod http;
@@ -49,9 +49,13 @@
 const DEFAULT_EXTRACT_MAX_CHARS: usize = 200_000;
 const MAX_EXTRACT_INPUT_BYTES: usize = 50 * 1024 * 1024;
 const MAX_PROJECT_ONBOARDING_IMPRESSIONS: u8 = 4;
+const A2A_PROTOCOL_VERSION: &str = "1.0";
+const A2A_DEFAULT_TURN_TIMEOUT_MS: u64 = 180_000;
 static ATTACHMENT_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
 static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
 type PendingToolResponseSender = mpsc::UnboundedSender<(String, bool, Option<ToolResult>)>;
+type A2ACancelSender = watch::Sender<bool>;
+type A2ACancelReceiver = watch::Receiver<bool>;
 #[derive(Debug, Clone)]
 struct Config {
     listen_host: String,
@@ -151,6 +155,8 @@
     shared_sessions: Arc<Mutex<HashMap<String, SharedSessionGrant>>>,
     approval_modes: Arc<Mutex<HashMap<String, String>>>,
     pending_tool_responses: Arc<Mutex<HashMap<String, PendingToolResponseSender>>>,
+    a2a_tasks: Arc<Mutex<HashMap<String, Value>>>,
+    a2a_cancel_senders: Arc<Mutex<HashMap<String, A2ACancelSender>>>,
 }
 
 #[derive(Clone, Debug, Default, Serialize)]
@@ -295,6 +301,8 @@
         shared_sessions: Arc::new(Mutex::new(shared_sessions)),
         approval_modes: Arc::new(Mutex::new(HashMap::new())),
         pending_tool_responses: Arc::new(Mutex::new(HashMap::new())),
+        a2a_tasks: Arc::new(Mutex::new(HashMap::new())),
+        a2a_cancel_senders: Arc::new(Mutex::new(HashMap::new())),
     };
 
     loop {
@@ -329,6 +337,16 @@
             return handle_chat_endpoint(stream, initial, head, state).await;
         }
 
+        if is_a2a_endpoint(&head) {
+            let response = handle_a2a_endpoint(&mut stream, &mut initial, head, &state).await;
+            stream
+                .write_all(&response)
+                .await
+                .map_err(|error| error.to_string())?;
+            let _ = stream.shutdown().await;
+            return Ok(());
+        }
+
         if is_local_endpoint(&head) {
             let response = handle_local_endpoint(&mut stream, &mut initial, head, &state).await;
             stream
@@ -508,6 +526,832 @@
     Some(request_id)
 }
 
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct A2APartBody {
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    text: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    url: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    data: Option<Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    metadata: Option<Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    filename: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    media_type: Option<String>,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct A2AMessageBody {
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    message_id: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    context_id: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    task_id: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    role: Option<String>,
+    #[serde(default)]
+    parts: Vec<A2APartBody>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    metadata: Option<Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    extensions: Option<Vec<String>>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    reference_task_ids: Option<Vec<String>>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct A2ASendMessageRequest {
+    message: A2AMessageBody,
+    #[serde(default)]
+    configuration: Option<Value>,
+    #[serde(default)]
+    metadata: Option<Value>,
+}
+
+#[derive(Debug, Default)]
+struct A2ATurnOutput {
+    assistant_text: String,
+    thinking_text: String,
+    usage: Option<TokenUsage>,
+    tools: Vec<Value>,
+}
+
+enum A2ATurnResult {
+    Completed(A2ATurnOutput),
+    Canceled,
+}
+
+fn is_a2a_endpoint(head: &RequestHead) -> bool {
+    if head.method == "OPTIONS" {
+        return head.path == "/.well-known/agent-card.json"
+            || head.path == "/message:send"
+            || head.path == "/tasks"
+            || head.path.starts_with("/tasks/");
+    }
+    matches!(
+        (head.method.as_str(), head.path.as_str()),
+        ("GET", "/.well-known/agent-card.json") | ("POST", "/message:send") | ("GET", "/tasks")
+    ) || (head.method == "GET" && a2a_task_id_from_get_path(&head.path).is_some())
+        || (head.method == "POST" && a2a_task_id_from_cancel_path(&head.path).is_some())
+}
+
+fn a2a_task_id_from_get_path(path: &str) -> Option<&str> {
+    let id = path.strip_prefix("/tasks/")?;
+    (!id.is_empty() && !id.contains('/') && !id.contains(':')).then_some(id)
+}
+
+fn a2a_task_id_from_cancel_path(path: &str) -> Option<&str> {
+    let id = path.strip_prefix("/tasks/")?.strip_suffix(":cancel")?;
+    (!id.is_empty() && !id.contains('/') && !id.contains(':')).then_some(id)
+}
+
+async fn handle_a2a_endpoint(
+    stream: &mut TcpStream,
+    initial: &mut Vec<u8>,
+    head: RequestHead,
+    state: &AppState,
+) -> Vec<u8> {
+    if head.method == "OPTIONS" {
+        return response(204, "text/plain; charset=utf-8", &[]);
+    }
+
+    if head.method == "GET" && head.path == "/.well-known/agent-card.json" {
+        return json_response(200, &a2a_agent_card(&head, &state.config));
+    }
+
+    if let Err(response) = authorize(&head, &state.config) {
+        return response;
+    }
+
+    if head.method == "GET" && head.path == "/tasks" {
+        let tasks = state
+            .a2a_tasks
+            .lock()
+            .await
+            .values()
+            .cloned()
+            .collect::<Vec<_>>();
+        return json_response(200, &serde_json::json!({ "tasks": tasks }));
+    }
+
+    if head.method == "GET" {
+        if let Some(task_id) = a2a_task_id_from_get_path(&head.path) {
+            let tasks = state.a2a_tasks.lock().await;
+            return tasks.get(task_id).map_or_else(
+                || a2a_error_response(404, "TASK_NOT_FOUND", "A2A task not found"),
+                |task| json_response(200, task),
+            );
+        }
+    }
+
+    if head.method == "POST" {
+        if let Some(task_id) = a2a_task_id_from_cancel_path(&head.path) {
+            return match cancel_a2a_task(state, task_id).await {
+                Ok(task) => json_response(200, &task),
+                Err(response) => response,
+            };
+        }
+    }
+
+    if head.method == "POST" && head.path == "/message:send" {
+        return handle_a2a_message_send(stream, initial, &head, state).await;
+    }
+
+    a2a_error_response(404, "NOT_FOUND", "A2A endpoint not found")
+}
+
+async fn cancel_a2a_task(state: &AppState, task_id: &str) -> Result<Value, Vec<u8>> {
+    let mut tasks = state.a2a_tasks.lock().await;
+    let Some(task) = tasks.get_mut(task_id) else {
+        return Err(a2a_error_response(
+            404,
+            "TASK_NOT_FOUND",
+            "A2A task not found",
+        ));
+    };
+    if a2a_task_is_terminal(task) {
+        return Err(a2a_error_response(
+            400,
+            "TASK_NOT_CANCELABLE",
+            "A2A task cannot be canceled from its current state",
+        ));
+    }
+    let context_id = task
+        .get("contextId")
+        .and_then(Value::as_str)
+        .unwrap_or("a2a")
+        .to_string();
+    task["status"] = serde_json::json!({
+        "state": "TASK_STATE_CANCELED",
+        "message": a2a_agent_message(&context_id, "Task canceled"),
+        "timestamp": now_rfc3339()
+    });
+    let task = task.clone();
+    drop(tasks);
+
+    if let Some(sender) = state.a2a_cancel_senders.lock().await.remove(task_id) {
+        let _ = sender.send(true);
+    }
+
+    Ok(task)
+}
+
+fn a2a_task_status_state(task: &Value) -> Option<&str> {
+    task.get("status")
+        .and_then(|status| status.get("state"))
+        .and_then(Value::as_str)
+}
+
+fn a2a_task_is_terminal(task: &Value) -> bool {
+    matches!(
+        a2a_task_status_state(task),
+        Some(
+            "TASK_STATE_COMPLETED"
+                | "TASK_STATE_FAILED"
+                | "TASK_STATE_CANCELED"
+                | "TASK_STATE_REJECTED"
+        )
+    )
+}
+
+async fn a2a_resolve_send_task_id(
+    state: &AppState,
+    request: &A2ASendMessageRequest,
+) -> Result<String, Vec<u8>> {
+    let Some(task_id) = request
+        .message
+        .task_id
+        .as_deref()
+        .map(str::trim)
+        .filter(|task_id| !task_id.is_empty())
+    else {
+        return Ok(generate_a2a_id("maestro-task"));
+    };
+
+    let tasks = state.a2a_tasks.lock().await;
+    let Some(task) = tasks.get(task_id) else {
+        return Err(a2a_error_response(
+            404,
+            "TASK_NOT_FOUND",
+            "A2A task not found",
+        ));
+    };
+    if a2a_task_is_terminal(task) {
+        return Err(a2a_error_response(
+            400,
+            "UNSUPPORTED_OPERATION",
+            "A2A terminal tasks cannot accept more messages",
+        ));
+    }
+    if let Some(message_context_id) = request
+        .message
+        .context_id
+        .as_deref()
+        .map(str::trim)
+        .filter(|context_id| !context_id.is_empty())
+    {
+        if let Some(task_context_id) = task.get("contextId").and_then(Value::as_str) {
+            if message_context_id != task_context_id {
+                return Err(a2a_error_response(
+                    400,
+                    "INVALID_REQUEST",
+                    "A2A message contextId must match the referenced task",
+                ));
+            }
+        }
+    }
+    Ok(task_id.to_string())
+}
+
+async fn a2a_existing_task_history(state: &AppState, task_id: &str) -> Vec<Value> {
+    state
+        .a2a_tasks
+        .lock()
+        .await
+        .get(task_id)
+        .and_then(|task| task.get("history"))
+        .and_then(Value::as_array)
+        .cloned()
+        .unwrap_or_default()
+}
+
+async fn store_a2a_task_unless_canceled(state: &AppState, task_id: &str, task: Value) -> Value {
+    let mut tasks = state.a2a_tasks.lock().await;
+    if let Some(existing) = tasks.get(task_id) {
+        if a2a_task_status_state(existing) == Some("TASK_STATE_CANCELED") {
+            return existing.clone();
+        }
+    }
+    tasks.insert(task_id.to_string(), task.clone());
+    task
+}
+
+async fn handle_a2a_message_send(
+    stream: &mut TcpStream,
+    initial: &mut Vec<u8>,
+    head: &RequestHead,
+    state: &AppState,
+) -> Vec<u8> {
+    let body = match read_request_body(stream, initial, head).await {
+        Ok(body) => body,
+        Err(error) => return a2a_error_response(400, "INVALID_REQUEST", &error),
+    };
+    let request: A2ASendMessageRequest = match serde_json::from_slice(&body) {
+        Ok(request) => request,
+        Err(error) => {
+            return a2a_error_response(
+                400,
+                "INVALID_REQUEST",
+                &format!("invalid A2A message request: {error}"),
+            );
+        }
+    };
+
+    let Some(prompt) = a2a_message_text(&request.message) else {
+        return a2a_error_response(
+            400,
+            "INVALID_REQUEST",
+            "A2A message must contain at least one text part",
+        );
+    };
+
+    let context_id = a2a_context_id(&request, head);
+    let task_id = match a2a_resolve_send_task_id(state, &request).await {
+        Ok(task_id) => task_id,
+        Err(response) => return response,
+    };
+    let user_message = a2a_user_message_value(&request.message, &context_id);
+    let mut history = a2a_existing_task_history(state, &task_id).await;
+    history.push(user_message.clone());
+
+    let metadata = a2a_task_metadata(head, &request);
+    let (cancel_tx, cancel_rx) = watch::channel(false);
+    state
+        .a2a_cancel_senders
+        .lock()
+        .await
+        .insert(task_id.clone(), cancel_tx);
+    if a2a_return_immediately(&request) {
+        let accepted_message = a2a_agent_message(&context_id, "Maestro accepted the A2A task.");
+        let mut accepted_history = history.clone();
+        accepted_history.push(accepted_message.clone());
+        let task = a2a_task_value(
+            &task_id,
+            &context_id,
+            "TASK_STATE_WORKING",
+            accepted_message.clone(),
+            accepted_history.clone(),
+            Vec::new(),
+            metadata.clone(),
+        );
+        state
+            .a2a_tasks
+            .lock()
+            .await
+            .insert(task_id.clone(), task.clone());
+        let state = state.clone();
+        tokio::spawn(async move {
+            let _ = complete_a2a_task(
+                &state,
+                prompt,
+                task_id,
+                context_id,
+                accepted_history,
+                metadata,
+                cancel_rx,
+            )
+            .await;
+        });
+        return json_response(200, &serde_json::json!({ "task": task }));
+    }
+
+    let task = complete_a2a_task(
+        state, prompt, task_id, context_id, history, metadata, cancel_rx,
+    )
+    .await;
+    json_response(200, &serde_json::json!({ "task": task }))
+}
+
+async fn complete_a2a_task(
+    state: &AppState,
+    prompt: String,
+    task_id: String,
+    context_id: String,
+    mut history: Vec<Value>,
+    mut metadata: Value,
+    cancel_rx: A2ACancelReceiver,
+) -> Value {
+    let turn = match run_a2a_native_turn(state, prompt, cancel_rx).await {
+        Ok(A2ATurnResult::Completed(turn)) => turn,
+        Ok(A2ATurnResult::Canceled) => {
+            let message = a2a_agent_message(&context_id, "Task canceled");
+            history.push(message.clone());
+            let task = a2a_task_value(
+                &task_id,
+                &context_id,
+                "TASK_STATE_CANCELED",
+                message,
+                history,
+                Vec::new(),
+                metadata,
+            );
+            let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+            state.a2a_cancel_senders.lock().await.remove(&task_id);
+            return task;
+        }
+        Err(error) => {
+            let message = a2a_agent_message(&context_id, &error);
+            history.push(message.clone());
+            let task = a2a_task_value(
+                &task_id,
+                &context_id,
+                "TASK_STATE_FAILED",
+                message.clone(),
+                history,
+                Vec::new(),
+                metadata,
+            );
+            let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+            state.a2a_cancel_senders.lock().await.remove(&task_id);
+            return task;
+        }
+    };
+
+    let assistant_text = if turn.assistant_text.trim().is_empty() {
+        "Maestro completed the A2A task without a text response.".to_string()
+    } else {
+        turn.assistant_text
+    };
+    let agent_message = a2a_agent_message(&context_id, &assistant_text);
+    if !turn.thinking_text.trim().is_empty() {
+        metadata["thinking"] = Value::String(turn.thinking_text);
+    }
+    if !turn.tools.is_empty() {
+        metadata["tools"] = Value::Array(turn.tools);
+    }
+    if let Some(usage) = turn.usage {
+        metadata["usage"] = serde_json::json!({
+            "input": usage.input_tokens,
+            "output": usage.output_tokens,
+            "cacheRead": usage.cache_read_tokens,
+            "cacheWrite": usage.cache_write_tokens,
+            "cost": usage.cost.unwrap_or(0.0)
+        });
+    }
+    let task = a2a_task_value(
+        &task_id,
+        &context_id,
+        "TASK_STATE_COMPLETED",
+        agent_message.clone(),
+        {
+            history.push(agent_message);
+            history
+        },
+        vec![serde_json::json!({
+            "artifactId": format!("{task_id}-assistant-response"),
+            "name": "assistant-response",
+            "parts": [{ "text": assistant_text, "mediaType": "text/plain" }]
+        })],
+        metadata,
+    );
+    let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+    state.a2a_cancel_senders.lock().await.remove(&task_id);
+    task
+}
+
+fn a2a_agent_card(head: &RequestHead, config: &Config) -> Value {
+    let base_url = a2a_public_base_url(head, config);
+    serde_json::json!({
+        "protocolVersion": A2A_PROTOCOL_VERSION,
+        "name": trimmed_env("MAESTRO_A2A_AGENT_NAME")
+            .unwrap_or_else(|| "Maestro Desktop Agent".to_string()),
+        "description": "Local Maestro Rust/TS TUI agent endpoint for A2A task delegation.",
+        "url": base_url,
+        "preferredTransport": "HTTP+JSON",
+        "supportedInterfaces": [{
+            "url": base_url,
+            "protocolBinding": "HTTP+JSON",
+            "protocolVersion": A2A_PROTOCOL_VERSION
+        }],
+        "provider": {
+            "organization": "EvalOps",
+            "url": "https://evalops.com"
+        },
+        "version": env!("CARGO_PKG_VERSION"),
+        "capabilities": {
+            "streaming": false,
+            "pushNotifications": false,
+            "extendedAgentCard": false
+        },
+        "defaultInputModes": ["text/plain"],
+        "defaultOutputModes": ["text/plain", "application/json"],
+        "skills": [{
+            "id": "maestro-tui-turn",
+            "name": "Maestro TUI turn",
+            "description": "Run a prompt through the local Maestro native TUI agent runner.",
+            "tags": ["maestro", "tui", "codex", "a2a"],
+            "examples": [
+                "Review the current workspace and summarize the next highest leverage action."
+            ],
+            "inputModes": ["text/plain"],
+            "outputModes": ["text/plain", "application/json"]
+        }]
+    })
+}
+
+fn a2a_public_base_url(head: &RequestHead, config: &Config) -> String {
+    if let Some(url) =
+        trimmed_env("MAESTRO_A2A_PUBLIC_URL").or_else(|| trimmed_env("MAESTRO_CONTROL_PUBLIC_URL"))
+    {
+        return url.trim_end_matches('/').to_string();
+    }
+    let proto = head
+        .headers
+        .get("x-forwarded-proto")
+        .and_then(|value| value.split(',').next())
+        .map(str::trim)
+        .filter(|value| !value.is_empty())
+        .unwrap_or("http");
+    let host = head
+        .headers
+        .get("host")
+        .map(String::as_str)
+        .filter(|host| !host.trim().is_empty())
+        .map(str::trim)
+        .map(str::to_string)
+        .unwrap_or_else(|| {
+            let host = if config.listen_host == "0.0.0.0" || config.listen_host == "::" {
+                "127.0.0.1"
+            } else {
+                config.listen_host.as_str()
+            };
+            format!("{host}:{}", config.listen_port)
+        });
+    format!("{proto}://{host}")
+}
+
+fn a2a_message_text(message: &A2AMessageBody) -> Option<String> {
+    let text = message
+        .parts
+        .iter()
+        .filter_map(|part| part.text.as_deref())
+        .map(str::trim)
+        .filter(|part| !part.is_empty())
+        .collect::<Vec<_>>()
+        .join("\n\n");
+    (!text.is_empty()).then_some(text)
+}
+
+fn a2a_context_id(request: &A2ASendMessageRequest, head: &RequestHead) -> String {
+    request
+        .message
+        .context_id
+        .as_deref()
+        .or_else(|| {
+            request
+                .message
+                .metadata
+                .as_ref()
+                .and_then(|metadata| metadata.get("sessionId").and_then(Value::as_str))
+        })
+        .or_else(|| head.headers.get("x-evalops-session-id").map(String::as_str))
+        .or_else(|| head.headers.get("x-maestro-session-id").map(String::as_str))
+        .map(str::trim)
+        .filter(|value| !value.is_empty())
+        .map(str::to_string)
+        .unwrap_or_else(|| generate_a2a_id("maestro-context"))
+}
+
+fn a2a_user_message_value(message: &A2AMessageBody, context_id: &str) -> Value {
+    let mut value = serde_json::to_value(message).unwrap_or_else(|_| serde_json::json!({}));
+    if let Value::Object(object) = &mut value {
+        object
+            .entry("messageId")
+            .or_insert_with(|| Value::String(generate_a2a_id("maestro-message")));
+        object
+            .entry("contextId")
+            .or_insert_with(|| Value::String(context_id.to_string()));
+        object
+            .entry("role")
+            .or_insert_with(|| Value::String("ROLE_USER".to_string()));
+    }
+    value
+}
+
+fn a2a_agent_message(context_id: &str, text: &str) -> Value {
+    serde_json::json!({
+        "messageId": generate_a2a_id("maestro-message"),
+        "contextId": context_id,
+        "role": "ROLE_AGENT",
+        "parts": [{ "text": text, "mediaType": "text/plain" }],
+        "metadata": {
+            "runtime": "maestro-rust-control-plane",
+            "surface": "rust-tui"
+        }
+    })
+}
+
+fn a2a_task_value(
+    task_id: &str,
+    context_id: &str,
+    state: &str,
+    status_message: Value,
+    history: Vec<Value>,
+    artifacts: Vec<Value>,
+    metadata: Value,
+) -> Value {
+    serde_json::json!({
+        "id": task_id,
+        "contextId": context_id,
+        "status": {
+            "state": state,
+            "message": status_message,
+            "timestamp": now_rfc3339()
+        },
+        "history": history,
+        "artifacts": artifacts,
+        "metadata": metadata
+    })
+}
+
+fn a2a_task_metadata(head: &RequestHead, request: &A2ASendMessageRequest) -> Value {
+    let mut metadata = Map::new();
+    metadata.insert(
+        "runtime".to_string(),
+        Value::String("maestro-rust-control-plane".to_string()),
+    );
+    metadata.insert("surface".to_string(), Value::String("rust-tui".to_string()));
+    metadata.insert(
+        "a2aProtocolVersion".to_string(),
+        Value::String(A2A_PROTOCOL_VERSION.to_string()),
+    );
+    for (field, header) in [
+        ("workspaceId", "x-evalops-workspace-id"),
+        ("agentId", "x-evalops-agent-id"),
+        ("sessionId", "x-evalops-session-id"),
+        ("actorId", "x-evalops-actor-id"),
+        ("traceparent", "traceparent"),
+        ("tracestate", "tracestate"),
+    ] {
+        if let Some(value) = head.headers.get(header).map(String::as_str) {
+            if !value.trim().is_empty() {
+                metadata.insert(field.to_string(), Value::String(value.trim().to_string()));
+            }
+        }
+    }
+    if let Some(Value::Object(request_metadata)) = request.metadata.as_ref() {
+        for (key, value) in request_metadata {
+            metadata.entry(key.clone()).or_insert_with(|| value.clone());
+        }
+    }
+    if let Some(configuration) = request.configuration.as_ref() {
+        metadata
+            .entry("configuration".to_string())
+            .or_insert_with(|| configuration.clone());
+    }
+    if let Some(Value::Object(message_metadata)) = request.message.metadata.as_ref() {
+        for (key, value) in message_metadata {
+            metadata.entry(key.clone()).or_insert_with(|| value.clone());
+        }
+    }
+    Value::Object(metadata)
+}
+
+fn a2a_return_immediately(request: &A2ASendMessageRequest) -> bool {
+    request
+        .configuration
+        .as_ref()
+        .and_then(|configuration| configuration.get("returnImmediately"))
+        .and_then(Value::as_bool)
+        .unwrap_or(false)
+}
+
+async fn run_a2a_native_turn(
+    state: &AppState,
+    prompt: String,
+    mut cancel_rx: A2ACancelReceiver,
+) -> Result<A2ATurnResult, String> {
+    if *cancel_rx.borrow() {
+        return Ok(A2ATurnResult::Canceled);
+    }
+
+    if let Some(response) = trimmed_env("MAESTRO_A2A_FAKE_RESPONSE") {
+        if a2a_wait_for_fake_response_delay(&mut cancel_rx).await {
+            return Ok(A2ATurnResult::Canceled);
+        }
+        return Ok(A2ATurnResult::Completed(A2ATurnOutput {
+            assistant_text: response,
+            ..Default::default()
+        }));
+    }
+
+    let model = if let Some(model) = trimmed_env("MAESTRO_A2A_MODEL") {
+        model
+    } else {
+        let selected = state.selected_model.lock().await;
+        format!("{}/{}", selected.provider, selected.id)
+    };
+    let config = NativeAgentConfig {
+        model,
+        cwd: state.config.cwd.to_string_lossy().to_string(),
+        system_prompt: Some(
+            trimmed_env("MAESTRO_A2A_SYSTEM_PROMPT").unwrap_or_else(|| {
+                "You are the local Maestro Desktop A2A agent. Complete delegated work from peer agents clearly and concisely.".to_string()
+            }),
+        ),
+        thinking_enabled: truthy_env("MAESTRO_A2A_THINKING"),
+        thinking_budget: env::var("MAESTRO_A2A_THINKING_BUDGET")
+            .ok()
+            .and_then(|value| value.parse().ok())
+            .unwrap_or(10_000),
+        ..NativeAgentConfig::default()
+    };
+    let (agent, mut events) = NativeAgent::new(config).map_err(|error| error.to_string())?;
+    agent
+        .prompt(prompt, Vec::new())
+        .await
+        .map_err(|error| error.to_string())?;
+
+    let timeout = Duration::from_millis(env_u64(
+        "MAESTRO_A2A_TURN_TIMEOUT_MS",
+        A2A_DEFAULT_TURN_TIMEOUT_MS,
+    ));
+    let approval_mode = trimmed_env("MAESTRO_A2A_TOOL_APPROVAL")
+        .unwrap_or_else(|| "fail".to_string())
+        .to_ascii_lowercase();
+    let auto_approve_tools = matches!(approval_mode.as_str(), "auto" | "approve" | "approved");
+    let mut output = A2ATurnOutput::default();
+    let mut last_error: Option<String> = None;
+    let mut response_ended = false;
+    let turn_timeout = tokio::time::sleep(timeout);
+    tokio::pin!(turn_timeout);
+
+    loop {
+        let event = tokio::select! {
+            _ = &mut turn_timeout => {
+                agent.cancel();
... diff truncated: showing 800 of 1510 lines

You can send follow-ups to the cloud agent here.

Comment thread packages/control-plane-rs/src/main.rs
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fae5207f8f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/control-plane-rs/src/main.rs Outdated
Comment thread packages/control-plane-rs/src/main.rs Outdated
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4a23e05aa5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread scripts/codex-a2a-bridge.py Outdated
Comment thread scripts/codex-a2a-bridge.py Outdated
Comment thread packages/control-plane-rs/src/main.rs Outdated
Comment thread packages/control-plane-rs/src/main.rs
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e38158558a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread scripts/codex-a2a-bridge.py
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 44a69d1a68

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread scripts/codex-a2a-bridge.py Outdated
Comment thread scripts/smoke-maestro-a2a-local.ts Outdated
Comment thread packages/control-plane-rs/src/main.rs
@cursor

This comment has been minimized.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 18400f7e43

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/control-plane-rs/src/main.rs Outdated
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Cancel task omits artifact clearing unlike Python bridge
    • The Rust A2A cancel path now clears existing task artifacts and includes a regression test for canceling an INPUT_REQUIRED task with stale artifacts.
Preview (081c27c74e)
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -104,6 +104,8 @@
     "smoke": "node scripts/smoke-cli.js",
     "smoke:local-e2e": "node scripts/smoke-cli.js",
     "smoke:event-bus": "tsx scripts/smoke-maestro-event-bus.ts",
+    "smoke:a2a-local": "tsx scripts/smoke-maestro-a2a-local.ts",
+    "a2a:codex-bridge": "python3 scripts/codex-a2a-bridge.py",
     "smoke:headless": "node scripts/smoke-headless.js",
     "headless:responsiveness": "node scripts/headless-responsiveness-harness.js",
     "smoke:pack": "node scripts/smoke-packed-cli.js",

diff --git a/packages/control-plane-rs/src/http.rs b/packages/control-plane-rs/src/http.rs
--- a/packages/control-plane-rs/src/http.rs
+++ b/packages/control-plane-rs/src/http.rs
@@ -11,7 +11,7 @@
 
 const MAX_HEADER_BYTES: usize = 64 * 1024;
 pub(crate) const MAX_JSON_BODY_BYTES: usize = 32 * 1024 * 1024;
-const CORS_ALLOW_HEADERS: &str = "authorization,content-type,x-composer-artifact-access,x-composer-api-key,x-composer-approval-mode,x-composer-client,x-composer-client-tools,x-composer-csrf,x-composer-agent-id,x-composer-slim-events,x-composer-workspace,x-composer-workspace-id,x-maestro-artifact-access,x-maestro-api-key,x-maestro-approval-mode,x-maestro-agent-id,x-maestro-client,x-maestro-client-tools,x-maestro-csrf,x-maestro-slim-events,x-maestro-workspace,x-maestro-workspace-id,x-csrf-token,x-xsrf-token";
+const CORS_ALLOW_HEADERS: &str = "authorization,content-type,a2a-version,a2a-extensions,traceparent,tracestate,x-organization-id,x-evalops-agent-id,x-evalops-actor-id,x-evalops-session-id,x-evalops-workspace-id,x-composer-artifact-access,x-composer-api-key,x-composer-approval-mode,x-composer-client,x-composer-client-tools,x-composer-csrf,x-composer-agent-id,x-composer-slim-events,x-composer-workspace,x-composer-workspace-id,x-maestro-artifact-access,x-maestro-api-key,x-maestro-approval-mode,x-maestro-agent-id,x-maestro-client,x-maestro-client-tools,x-maestro-csrf,x-maestro-slim-events,x-maestro-workspace,x-maestro-workspace-id,x-csrf-token,x-xsrf-token";
 
 #[derive(Debug)]
 pub(crate) struct RequestHead {

diff --git a/packages/control-plane-rs/src/main.rs b/packages/control-plane-rs/src/main.rs
--- a/packages/control-plane-rs/src/main.rs
+++ b/packages/control-plane-rs/src/main.rs
@@ -17,7 +17,7 @@
 use tokio::io::{AsyncReadExt, AsyncWriteExt};
 use tokio::net::{TcpListener, TcpStream};
 use tokio::process::Command;
-use tokio::sync::{mpsc, Mutex};
+use tokio::sync::{mpsc, watch, Mutex};
 
 mod auth;
 mod http;
@@ -49,9 +49,15 @@
 const DEFAULT_EXTRACT_MAX_CHARS: usize = 200_000;
 const MAX_EXTRACT_INPUT_BYTES: usize = 50 * 1024 * 1024;
 const MAX_PROJECT_ONBOARDING_IMPRESSIONS: u8 = 4;
+const A2A_PROTOCOL_VERSION: &str = "1.0";
+const A2A_DEFAULT_TURN_TIMEOUT_MS: u64 = 180_000;
+const A2A_DEFAULT_RESPONSE_END_SETTLE_MS: u64 = 250;
+const A2A_TERMINAL_TASK_STORE_LIMIT: usize = 128;
 static ATTACHMENT_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
 static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
 type PendingToolResponseSender = mpsc::UnboundedSender<(String, bool, Option<ToolResult>)>;
+type A2ACancelSender = watch::Sender<bool>;
+type A2ACancelReceiver = watch::Receiver<bool>;
 #[derive(Debug, Clone)]
 struct Config {
     listen_host: String,
@@ -151,6 +157,8 @@
     shared_sessions: Arc<Mutex<HashMap<String, SharedSessionGrant>>>,
     approval_modes: Arc<Mutex<HashMap<String, String>>>,
     pending_tool_responses: Arc<Mutex<HashMap<String, PendingToolResponseSender>>>,
+    a2a_tasks: Arc<Mutex<HashMap<String, Value>>>,
+    a2a_cancel_senders: Arc<Mutex<HashMap<String, A2ACancelSender>>>,
 }
 
 #[derive(Clone, Debug, Default, Serialize)]
@@ -295,6 +303,8 @@
         shared_sessions: Arc::new(Mutex::new(shared_sessions)),
         approval_modes: Arc::new(Mutex::new(HashMap::new())),
         pending_tool_responses: Arc::new(Mutex::new(HashMap::new())),
+        a2a_tasks: Arc::new(Mutex::new(HashMap::new())),
+        a2a_cancel_senders: Arc::new(Mutex::new(HashMap::new())),
     };
 
     loop {
@@ -329,6 +339,16 @@
             return handle_chat_endpoint(stream, initial, head, state).await;
         }
 
+        if is_a2a_endpoint(&head) {
+            let response = handle_a2a_endpoint(&mut stream, &mut initial, head, &state).await;
+            stream
+                .write_all(&response)
+                .await
+                .map_err(|error| error.to_string())?;
+            let _ = stream.shutdown().await;
+            return Ok(());
+        }
+
         if is_local_endpoint(&head) {
             let response = handle_local_endpoint(&mut stream, &mut initial, head, &state).await;
             stream
@@ -508,6 +528,970 @@
     Some(request_id)
 }
 
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct A2APartBody {
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    text: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    url: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    data: Option<Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    metadata: Option<Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    filename: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    media_type: Option<String>,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct A2AMessageBody {
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    message_id: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    context_id: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    task_id: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    role: Option<String>,
+    #[serde(default)]
+    parts: Vec<A2APartBody>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    metadata: Option<Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    extensions: Option<Vec<String>>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    reference_task_ids: Option<Vec<String>>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct A2ASendMessageRequest {
+    message: A2AMessageBody,
+    #[serde(default)]
+    configuration: Option<Value>,
+    #[serde(default)]
+    metadata: Option<Value>,
+}
+
+#[derive(Debug, Default)]
+struct A2ATurnOutput {
+    assistant_text: String,
+    thinking_text: String,
+    usage: Option<TokenUsage>,
+    tools: Vec<Value>,
+}
+
+enum A2ATurnResult {
+    Completed(A2ATurnOutput),
+    Canceled,
+}
+
+#[derive(Debug)]
+struct A2ASendTarget {
+    task_id: String,
+    context_id: String,
+    history: Vec<Value>,
+}
+
+fn is_a2a_endpoint(head: &RequestHead) -> bool {
+    if head.method == "OPTIONS" {
+        return head.path == "/.well-known/agent-card.json"
+            || head.path == "/message:send"
+            || head.path == "/tasks"
+            || head.path.starts_with("/tasks/");
+    }
+    matches!(
+        (head.method.as_str(), head.path.as_str()),
+        ("GET", "/.well-known/agent-card.json") | ("POST", "/message:send") | ("GET", "/tasks")
+    ) || (head.method == "GET" && a2a_task_id_from_get_path(&head.path).is_some())
+        || (head.method == "POST" && a2a_task_id_from_cancel_path(&head.path).is_some())
+}
+
+fn a2a_task_id_from_get_path(path: &str) -> Option<&str> {
+    let id = path.strip_prefix("/tasks/")?;
+    (!id.is_empty() && !id.contains('/') && !id.contains(':')).then_some(id)
+}
+
+fn a2a_task_id_from_cancel_path(path: &str) -> Option<&str> {
+    let id = path.strip_prefix("/tasks/")?.strip_suffix(":cancel")?;
+    (!id.is_empty() && !id.contains('/') && !id.contains(':')).then_some(id)
+}
+
+async fn handle_a2a_endpoint(
+    stream: &mut TcpStream,
+    initial: &mut Vec<u8>,
+    head: RequestHead,
+    state: &AppState,
+) -> Vec<u8> {
+    if head.method == "OPTIONS" {
+        return response(204, "text/plain; charset=utf-8", &[]);
+    }
+
+    if head.method == "GET" && head.path == "/.well-known/agent-card.json" {
+        return json_response(200, &a2a_agent_card(&head, &state.config));
+    }
+
+    if let Err(response) = authorize(&head, &state.config) {
+        return response;
+    }
+
+    if head.method == "GET" && head.path == "/tasks" {
+        let tasks = state
+            .a2a_tasks
+            .lock()
+            .await
+            .values()
+            .cloned()
+            .collect::<Vec<_>>();
+        return json_response(200, &serde_json::json!({ "tasks": tasks }));
+    }
+
+    if head.method == "GET" {
+        if let Some(task_id) = a2a_task_id_from_get_path(&head.path) {
+            let tasks = state.a2a_tasks.lock().await;
+            return tasks.get(task_id).map_or_else(
+                || a2a_error_response(404, "TASK_NOT_FOUND", "A2A task not found"),
+                |task| json_response(200, task),
+            );
+        }
+    }
+
+    if head.method == "POST" {
+        if let Some(task_id) = a2a_task_id_from_cancel_path(&head.path) {
+            return match cancel_a2a_task(state, task_id).await {
+                Ok(task) => json_response(200, &task),
+                Err(response) => response,
+            };
+        }
+    }
+
+    if head.method == "POST" && head.path == "/message:send" {
+        return handle_a2a_message_send(stream, initial, &head, state).await;
+    }
+
+    a2a_error_response(404, "NOT_FOUND", "A2A endpoint not found")
+}
+
+async fn cancel_a2a_task(state: &AppState, task_id: &str) -> Result<Value, Vec<u8>> {
+    let mut tasks = state.a2a_tasks.lock().await;
+    let Some(task) = tasks.get_mut(task_id) else {
+        return Err(a2a_error_response(
+            404,
+            "TASK_NOT_FOUND",
+            "A2A task not found",
+        ));
+    };
+    if a2a_task_is_terminal(task) {
+        return Err(a2a_error_response(
+            400,
+            "TASK_NOT_CANCELABLE",
+            "A2A task cannot be canceled from its current state",
+        ));
+    }
+    let context_id = task
+        .get("contextId")
+        .and_then(Value::as_str)
+        .unwrap_or("a2a")
+        .to_string();
+    task["status"] = serde_json::json!({
+        "state": "TASK_STATE_CANCELED",
+        "message": a2a_agent_message(&context_id, "Task canceled"),
+        "timestamp": now_rfc3339()
+    });
+    task["artifacts"] = serde_json::json!([]);
+    let task = task.clone();
+    prune_a2a_terminal_tasks(&mut tasks);
+    drop(tasks);
+
+    if let Some(sender) = state.a2a_cancel_senders.lock().await.remove(task_id) {
+        let _ = sender.send(true);
+    }
+
+    Ok(task)
+}
+
+fn a2a_task_status_state(task: &Value) -> Option<&str> {
+    task.get("status")
+        .and_then(|status| status.get("state"))
+        .and_then(Value::as_str)
+}
+
+fn a2a_task_status_timestamp(task: &Value) -> Option<&str> {
+    task.get("status")
+        .and_then(|status| status.get("timestamp"))
+        .and_then(Value::as_str)
+}
+
+fn a2a_task_is_terminal(task: &Value) -> bool {
+    matches!(
+        a2a_task_status_state(task),
+        Some(
+            "TASK_STATE_COMPLETED"
+                | "TASK_STATE_FAILED"
+                | "TASK_STATE_CANCELED"
+                | "TASK_STATE_REJECTED"
+        )
+    )
+}
+
+fn a2a_task_accepts_message(task: &Value) -> bool {
+    a2a_task_status_state(task) == Some("TASK_STATE_INPUT_REQUIRED")
+}
+
+async fn claim_a2a_send_task(
+    state: &AppState,
+    request: &A2ASendMessageRequest,
+    head: &RequestHead,
+    metadata: Value,
+) -> Result<A2ASendTarget, Vec<u8>> {
+    let requested_task_id = request
+        .message
+        .task_id
+        .as_deref()
+        .map(str::trim)
+        .filter(|task_id| !task_id.is_empty());
+    let explicit_context_id = request
+        .message
+        .context_id
+        .as_deref()
+        .map(str::trim)
+        .filter(|context_id| !context_id.is_empty())
+        .map(str::to_string);
+
+    let mut tasks = state.a2a_tasks.lock().await;
+    let (task_id, context_id, mut history) = if let Some(task_id) = requested_task_id {
+        let Some(task) = tasks.get(task_id) else {
+            return Err(a2a_error_response(
+                404,
+                "TASK_NOT_FOUND",
+                "A2A task not found",
+            ));
+        };
+        if a2a_task_is_terminal(task) {
+            return Err(a2a_error_response(
+                400,
+                "UNSUPPORTED_OPERATION",
+                "A2A terminal tasks cannot accept more messages",
+            ));
+        }
+        if !a2a_task_accepts_message(task) {
+            return Err(a2a_error_response(
+                409,
+                "UNSUPPORTED_OPERATION",
+                "A2A task is not ready to accept another message",
+            ));
+        }
+
+        let task_context_id = task
+            .get("contextId")
+            .and_then(Value::as_str)
+            .map(str::trim)
+            .filter(|context_id| !context_id.is_empty())
+            .map(str::to_string);
+        if let (Some(message_context_id), Some(task_context_id)) =
+            (explicit_context_id.as_deref(), task_context_id.as_deref())
+        {
+            if message_context_id != task_context_id {
+                return Err(a2a_error_response(
+                    400,
+                    "INVALID_REQUEST",
+                    "A2A message contextId must match the referenced task",
+                ));
+            }
+        }
+        let context_id = explicit_context_id
+            .or(task_context_id)
+            .unwrap_or_else(|| a2a_context_id(request, head));
+        let history = task
+            .get("history")
+            .and_then(Value::as_array)
+            .cloned()
+            .unwrap_or_default();
+        (task_id.to_string(), context_id, history)
+    } else {
+        (
+            generate_a2a_id("maestro-task"),
+            explicit_context_id.unwrap_or_else(|| a2a_context_id(request, head)),
+            Vec::new(),
+        )
+    };
+    history.push(a2a_user_message_value(&request.message, &context_id));
+    let working_message = a2a_agent_message(&context_id, "Maestro is working on the A2A task.");
+    let task = a2a_task_value(
+        &task_id,
+        &context_id,
+        "TASK_STATE_WORKING",
+        working_message,
+        history.clone(),
+        Vec::new(),
+        metadata,
+    );
+    tasks.insert(task_id.clone(), task);
+    prune_a2a_terminal_tasks(&mut tasks);
+    Ok(A2ASendTarget {
+        task_id,
+        context_id,
+        history,
+    })
+}
+
+async fn a2a_canceled_task(state: &AppState, task_id: &str) -> Option<Value> {
+    state.a2a_tasks.lock().await.get(task_id).and_then(|task| {
+        (a2a_task_status_state(task) == Some("TASK_STATE_CANCELED")).then(|| task.clone())
+    })
+}
+
+async fn store_a2a_task_unless_canceled(state: &AppState, task_id: &str, task: Value) -> Value {
+    let mut tasks = state.a2a_tasks.lock().await;
+    if let Some(existing) = tasks.get(task_id) {
+        if a2a_task_status_state(existing) == Some("TASK_STATE_CANCELED") {
+            return existing.clone();
+        }
+    }
+    tasks.insert(task_id.to_string(), task.clone());
+    prune_a2a_terminal_tasks(&mut tasks);
+    task
+}
+
+fn prune_a2a_terminal_tasks(tasks: &mut HashMap<String, Value>) {
+    let mut terminal_tasks = tasks
+        .iter()
+        .filter(|(_, task)| a2a_task_is_terminal(task))
+        .map(|(task_id, task)| {
+            (
+                task_id.clone(),
+                a2a_task_status_timestamp(task)
+                    .unwrap_or_default()
+                    .to_string(),
+            )
+        })
+        .collect::<Vec<_>>();
+    if terminal_tasks.len() <= A2A_TERMINAL_TASK_STORE_LIMIT {
+        return;
+    }
+    terminal_tasks.sort_by(|(left_id, left_timestamp), (right_id, right_timestamp)| {
+        left_timestamp
+            .cmp(right_timestamp)
+            .then_with(|| left_id.cmp(right_id))
+    });
+    let overflow = terminal_tasks.len() - A2A_TERMINAL_TASK_STORE_LIMIT;
+    for (task_id, _) in terminal_tasks.into_iter().take(overflow) {
+        tasks.remove(&task_id);
+    }
+}
+
+async fn register_a2a_cancel_sender(
+    state: &AppState,
+    task_id: &str,
+    cancel_tx: A2ACancelSender,
+) -> Result<(), Vec<u8>> {
+    let mut senders = state.a2a_cancel_senders.lock().await;
+    if senders.contains_key(task_id) {
+        return Err(a2a_error_response(
+            409,
+            "UNSUPPORTED_OPERATION",
+            "A2A task is already running",
+        ));
+    }
+    senders.insert(task_id.to_string(), cancel_tx);
+    Ok(())
+}
+
+async fn handle_a2a_message_send(
+    stream: &mut TcpStream,
+    initial: &mut Vec<u8>,
+    head: &RequestHead,
+    state: &AppState,
+) -> Vec<u8> {
+    let body = match read_request_body(stream, initial, head).await {
+        Ok(body) => body,
+        Err(error) => return a2a_error_response(400, "INVALID_REQUEST", &error),
+    };
+    let request: A2ASendMessageRequest = match serde_json::from_slice(&body) {
+        Ok(request) => request,
+        Err(error) => {
+            return a2a_error_response(
+                400,
+                "INVALID_REQUEST",
+                &format!("invalid A2A message request: {error}"),
+            );
+        }
+    };
+
+    let Some(prompt) = a2a_message_text(&request.message) else {
+        return a2a_error_response(
+            400,
+            "INVALID_REQUEST",
+            "A2A message must contain at least one text part",
+        );
+    };
+
+    let metadata = a2a_task_metadata(head, &request);
+    let target = match claim_a2a_send_task(state, &request, head, metadata.clone()).await {
+        Ok(target) => target,
+        Err(response) => return response,
+    };
+    let task_id = target.task_id;
+    let context_id = target.context_id;
+    let history = target.history;
+
+    let (cancel_tx, cancel_rx) = watch::channel(false);
+    if let Err(response) = register_a2a_cancel_sender(state, &task_id, cancel_tx).await {
+        return response;
+    }
+    if let Some(task) = a2a_canceled_task(state, &task_id).await {
+        state.a2a_cancel_senders.lock().await.remove(&task_id);
+        return json_response(200, &serde_json::json!({ "task": task }));
+    }
+    if a2a_return_immediately(&request) {
+        let accepted_message = a2a_agent_message(&context_id, "Maestro accepted the A2A task.");
+        let mut accepted_history = history.clone();
+        accepted_history.push(accepted_message.clone());
+        let task = a2a_task_value(
+            &task_id,
+            &context_id,
+            "TASK_STATE_WORKING",
+            accepted_message.clone(),
+            accepted_history.clone(),
+            Vec::new(),
+            metadata.clone(),
+        );
+        let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+        let state = state.clone();
+        tokio::spawn(async move {
+            let _ = complete_a2a_task(
+                &state,
+                prompt,
+                task_id,
+                context_id,
+                accepted_history,
+                metadata,
+                cancel_rx,
+            )
+            .await;
+        });
+        return json_response(200, &serde_json::json!({ "task": task }));
+    }
+
+    let task = complete_a2a_task(
+        state, prompt, task_id, context_id, history, metadata, cancel_rx,
+    )
+    .await;
+    json_response(200, &serde_json::json!({ "task": task }))
+}
+
+async fn complete_a2a_task(
+    state: &AppState,
+    prompt: String,
+    task_id: String,
+    context_id: String,
+    mut history: Vec<Value>,
+    mut metadata: Value,
+    cancel_rx: A2ACancelReceiver,
+) -> Value {
+    let turn = match run_a2a_native_turn(state, prompt, cancel_rx).await {
+        Ok(A2ATurnResult::Completed(turn)) => turn,
+        Ok(A2ATurnResult::Canceled) => {
+            let message = a2a_agent_message(&context_id, "Task canceled");
+            history.push(message.clone());
+            let task = a2a_task_value(
+                &task_id,
+                &context_id,
+                "TASK_STATE_CANCELED",
+                message,
+                history,
+                Vec::new(),
+                metadata,
+            );
+            let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+            state.a2a_cancel_senders.lock().await.remove(&task_id);
+            return task;
+        }
+        Err(error) => {
+            let message = a2a_agent_message(&context_id, &error);
+            history.push(message.clone());
+            let task = a2a_task_value(
+                &task_id,
+                &context_id,
+                "TASK_STATE_FAILED",
+                message.clone(),
+                history,
+                Vec::new(),
+                metadata,
+            );
+            let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+            state.a2a_cancel_senders.lock().await.remove(&task_id);
+            return task;
+        }
+    };
+
+    let assistant_text = if turn.assistant_text.trim().is_empty() {
+        "Maestro completed the A2A task without a text response.".to_string()
+    } else {
+        turn.assistant_text
+    };
+    let agent_message = a2a_agent_message(&context_id, &assistant_text);
+    if !turn.thinking_text.trim().is_empty() {
+        metadata["thinking"] = Value::String(turn.thinking_text);
+    }
+    if !turn.tools.is_empty() {
+        metadata["tools"] = Value::Array(turn.tools);
+    }
+    if let Some(usage) = turn.usage {
+        metadata["usage"] = serde_json::json!({
+            "input": usage.input_tokens,
+            "output": usage.output_tokens,
+            "cacheRead": usage.cache_read_tokens,
+            "cacheWrite": usage.cache_write_tokens,
+            "cost": usage.cost.unwrap_or(0.0)
+        });
+    }
+    let task = a2a_task_value(
+        &task_id,
+        &context_id,
+        "TASK_STATE_COMPLETED",
+        agent_message.clone(),
+        {
+            history.push(agent_message);
+            history
+        },
+        vec![serde_json::json!({
+            "artifactId": format!("{task_id}-assistant-response"),
+            "name": "assistant-response",
+            "parts": [{ "text": assistant_text, "mediaType": "text/plain" }]
+        })],
+        metadata,
+    );
+    let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+    state.a2a_cancel_senders.lock().await.remove(&task_id);
+    task
+}
+
+fn a2a_agent_card(head: &RequestHead, config: &Config) -> Value {
+    let base_url = a2a_public_base_url(head, config);
+    serde_json::json!({
+        "protocolVersion": A2A_PROTOCOL_VERSION,
+        "name": trimmed_env("MAESTRO_A2A_AGENT_NAME")
+            .unwrap_or_else(|| "Maestro Desktop Agent".to_string()),
+        "description": "Local Maestro Rust/TS TUI agent endpoint for A2A task delegation.",
+        "url": base_url,
+        "preferredTransport": "HTTP+JSON",
+        "supportedInterfaces": [{
+            "url": base_url,
+            "protocolBinding": "HTTP+JSON",
+            "protocolVersion": A2A_PROTOCOL_VERSION
+        }],
+        "provider": {
+            "organization": "EvalOps",
+            "url": "https://evalops.com"
+        },
+        "version": env!("CARGO_PKG_VERSION"),
+        "capabilities": {
+            "streaming": false,
+            "pushNotifications": false,
+            "extendedAgentCard": false
+        },
+        "defaultInputModes": ["text/plain"],
+        "defaultOutputModes": ["text/plain", "application/json"],
+        "skills": [{
+            "id": "maestro-tui-turn",
+            "name": "Maestro TUI turn",
+            "description": "Run a prompt through the local Maestro native TUI agent runner.",
+            "tags": ["maestro", "tui", "codex", "a2a"],
+            "examples": [
+                "Review the current workspace and summarize the next highest leverage action."
+            ],
+            "inputModes": ["text/plain"],
+            "outputModes": ["text/plain", "application/json"]
+        }]
+    })
+}
+
+fn a2a_public_base_url(head: &RequestHead, config: &Config) -> String {
+    if let Some(url) =
+        trimmed_env("MAESTRO_A2A_PUBLIC_URL").or_else(|| trimmed_env("MAESTRO_CONTROL_PUBLIC_URL"))
+    {
+        return url.trim_end_matches('/').to_string();
+    }
+    let proto = head
+        .headers
+        .get("x-forwarded-proto")
+        .and_then(|value| value.split(',').next())
+        .map(str::trim)
+        .filter(|value| !value.is_empty())
+        .unwrap_or("http");
+    let host = head
+        .headers
+        .get("host")
+        .map(String::as_str)
+        .filter(|host| !host.trim().is_empty())
+        .map(str::trim)
+        .map(str::to_string)
+        .unwrap_or_else(|| {
+            let host = if config.listen_host == "0.0.0.0" || config.listen_host == "::" {
+                "127.0.0.1"
+            } else {
+                config.listen_host.as_str()
+            };
+            format!("{host}:{}", config.listen_port)
+        });
+    format!("{proto}://{host}")
+}
+
+fn a2a_message_text(message: &A2AMessageBody) -> Option<String> {
+    let text = message
+        .parts
+        .iter()
+        .filter_map(|part| part.text.as_deref())
+        .map(str::trim)
+        .filter(|part| !part.is_empty())
+        .collect::<Vec<_>>()
+        .join("\n\n");
+    (!text.is_empty()).then_some(text)
+}
+
+fn trimmed_non_empty_string(value: &str) -> Option<String> {
+    let value = value.trim();
+    (!value.is_empty()).then(|| value.to_string())
+}
+
+fn a2a_context_id(request: &A2ASendMessageRequest, head: &RequestHead) -> String {
+    request
+        .message
+        .context_id
+        .as_deref()
+        .and_then(trimmed_non_empty_string)
+        .or_else(|| {
+            request
+                .message
+                .metadata
+                .as_ref()
+                .and_then(|metadata| metadata.get("sessionId").and_then(Value::as_str))
+                .and_then(trimmed_non_empty_string)
+        })
+        .or_else(|| {
+            head.headers
+                .get("x-evalops-session-id")
+                .and_then(|value| trimmed_non_empty_string(value))
+        })
+        .or_else(|| {
+            head.headers
+                .get("x-maestro-session-id")
+                .and_then(|value| trimmed_non_empty_string(value))
+        })
+        .unwrap_or_else(|| generate_a2a_id("maestro-context"))
+}
+
+fn a2a_user_message_value(message: &A2AMessageBody, context_id: &str) -> Value {
+    let mut value = serde_json::to_value(message).unwrap_or_else(|_| serde_json::json!({}));
+    if let Value::Object(object) = &mut value {
+        object
+            .entry("messageId")
+            .or_insert_with(|| Value::String(generate_a2a_id("maestro-message")));
+        object.insert(
+            "contextId".to_string(),
+            Value::String(context_id.to_string()),
+        );
+        object
+            .entry("role")
+            .or_insert_with(|| Value::String("ROLE_USER".to_string()));
+    }
+    value
+}
+
+fn a2a_agent_message(context_id: &str, text: &str) -> Value {
+    serde_json::json!({
+        "messageId": generate_a2a_id("maestro-message"),
+        "contextId": context_id,
+        "role": "ROLE_AGENT",
+        "parts": [{ "text": text, "mediaType": "text/plain" }],
+        "metadata": {
+            "runtime": "maestro-rust-control-plane",
+            "surface": "rust-tui"
+        }
+    })
+}
+
+fn a2a_task_value(
+    task_id: &str,
+    context_id: &str,
+    state: &str,
+    status_message: Value,
+    history: Vec<Value>,
+    artifacts: Vec<Value>,
+    metadata: Value,
+) -> Value {
+    serde_json::json!({
+        "id": task_id,
+        "contextId": context_id,
+        "status": {
+            "state": state,
+            "message": status_message,
+            "timestamp": now_rfc3339()
+        },
+        "history": history,
+        "artifacts": artifacts,
+        "metadata": metadata
... diff truncated: showing 800 of 2439 lines

You can send follow-ups to the cloud agent here.

Comment thread packages/control-plane-rs/src/main.rs
@cursor
Copy link
Copy Markdown

cursor Bot commented May 15, 2026

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Unbounded in-memory task store never evicts entries
    • Bounded the in-memory A2A task store by pruning the oldest terminal tasks on updates while preserving active tasks and added coverage for eviction behavior.

You can send follow-ups to the cloud agent here.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 11b85c7c93

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread scripts/codex-a2a-bridge.py
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 801e843f55

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread scripts/codex-a2a-bridge.py Outdated
Comment thread packages/control-plane-rs/src/main.rs
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c23db5fcec

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread scripts/codex-a2a-bridge.py
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Resolved by another fix: Agent card URL derived from unvalidated Host header
    • The current branch already ignores request Host/forwarded-proto headers and derives the agent card URL from explicit config instead.
  • ✅ Resolved by another fix: Python cancel handler mutates shared task dict in-place
    • The current branch already rebuilds canceled tasks with deep-copied nested state before storing and returning them.
Preview (6e2346f3da)
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -104,6 +104,8 @@
     "smoke": "node scripts/smoke-cli.js",
     "smoke:local-e2e": "node scripts/smoke-cli.js",
     "smoke:event-bus": "tsx scripts/smoke-maestro-event-bus.ts",
+    "smoke:a2a-local": "tsx scripts/smoke-maestro-a2a-local.ts",
+    "a2a:codex-bridge": "python3 scripts/codex-a2a-bridge.py",
     "smoke:headless": "node scripts/smoke-headless.js",
     "headless:responsiveness": "node scripts/headless-responsiveness-harness.js",
     "smoke:pack": "node scripts/smoke-packed-cli.js",

diff --git a/packages/control-plane-rs/src/http.rs b/packages/control-plane-rs/src/http.rs
--- a/packages/control-plane-rs/src/http.rs
+++ b/packages/control-plane-rs/src/http.rs
@@ -11,7 +11,7 @@
 
 const MAX_HEADER_BYTES: usize = 64 * 1024;
 pub(crate) const MAX_JSON_BODY_BYTES: usize = 32 * 1024 * 1024;
-const CORS_ALLOW_HEADERS: &str = "authorization,content-type,x-composer-artifact-access,x-composer-api-key,x-composer-approval-mode,x-composer-client,x-composer-client-tools,x-composer-csrf,x-composer-agent-id,x-composer-slim-events,x-composer-workspace,x-composer-workspace-id,x-maestro-artifact-access,x-maestro-api-key,x-maestro-approval-mode,x-maestro-agent-id,x-maestro-client,x-maestro-client-tools,x-maestro-csrf,x-maestro-slim-events,x-maestro-workspace,x-maestro-workspace-id,x-csrf-token,x-xsrf-token";
+const CORS_ALLOW_HEADERS: &str = "authorization,content-type,a2a-version,a2a-extensions,traceparent,tracestate,x-organization-id,x-evalops-agent-id,x-evalops-actor-id,x-evalops-session-id,x-evalops-workspace-id,x-composer-artifact-access,x-composer-api-key,x-composer-approval-mode,x-composer-client,x-composer-client-tools,x-composer-csrf,x-composer-agent-id,x-composer-slim-events,x-composer-workspace,x-composer-workspace-id,x-maestro-artifact-access,x-maestro-api-key,x-maestro-approval-mode,x-maestro-agent-id,x-maestro-client,x-maestro-client-tools,x-maestro-csrf,x-maestro-slim-events,x-maestro-workspace,x-maestro-workspace-id,x-csrf-token,x-xsrf-token";
 
 #[derive(Debug)]
 pub(crate) struct RequestHead {

diff --git a/packages/control-plane-rs/src/main.rs b/packages/control-plane-rs/src/main.rs
--- a/packages/control-plane-rs/src/main.rs
+++ b/packages/control-plane-rs/src/main.rs
@@ -17,7 +17,7 @@
 use tokio::io::{AsyncReadExt, AsyncWriteExt};
 use tokio::net::{TcpListener, TcpStream};
 use tokio::process::Command;
-use tokio::sync::{mpsc, Mutex};
+use tokio::sync::{mpsc, watch, Mutex};
 
 mod auth;
 mod http;
@@ -49,9 +49,15 @@
 const DEFAULT_EXTRACT_MAX_CHARS: usize = 200_000;
 const MAX_EXTRACT_INPUT_BYTES: usize = 50 * 1024 * 1024;
 const MAX_PROJECT_ONBOARDING_IMPRESSIONS: u8 = 4;
+const A2A_PROTOCOL_VERSION: &str = "1.0";
+const A2A_DEFAULT_TURN_TIMEOUT_MS: u64 = 180_000;
+const A2A_DEFAULT_RESPONSE_END_SETTLE_MS: u64 = 250;
+const A2A_TERMINAL_TASK_STORE_LIMIT: usize = 128;
 static ATTACHMENT_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
 static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
 type PendingToolResponseSender = mpsc::UnboundedSender<(String, bool, Option<ToolResult>)>;
+type A2ACancelSender = watch::Sender<bool>;
+type A2ACancelReceiver = watch::Receiver<bool>;
 #[derive(Debug, Clone)]
 struct Config {
     listen_host: String,
@@ -151,6 +157,8 @@
     shared_sessions: Arc<Mutex<HashMap<String, SharedSessionGrant>>>,
     approval_modes: Arc<Mutex<HashMap<String, String>>>,
     pending_tool_responses: Arc<Mutex<HashMap<String, PendingToolResponseSender>>>,
+    a2a_tasks: Arc<Mutex<HashMap<String, Value>>>,
+    a2a_cancel_senders: Arc<Mutex<HashMap<String, A2ACancelSender>>>,
 }
 
 #[derive(Clone, Debug, Default, Serialize)]
@@ -295,6 +303,8 @@
         shared_sessions: Arc::new(Mutex::new(shared_sessions)),
         approval_modes: Arc::new(Mutex::new(HashMap::new())),
         pending_tool_responses: Arc::new(Mutex::new(HashMap::new())),
+        a2a_tasks: Arc::new(Mutex::new(HashMap::new())),
+        a2a_cancel_senders: Arc::new(Mutex::new(HashMap::new())),
     };
 
     loop {
@@ -329,6 +339,16 @@
             return handle_chat_endpoint(stream, initial, head, state).await;
         }
 
+        if is_a2a_endpoint(&head) {
+            let response = handle_a2a_endpoint(&mut stream, &mut initial, head, &state).await;
+            stream
+                .write_all(&response)
+                .await
+                .map_err(|error| error.to_string())?;
+            let _ = stream.shutdown().await;
+            return Ok(());
+        }
+
         if is_local_endpoint(&head) {
             let response = handle_local_endpoint(&mut stream, &mut initial, head, &state).await;
             stream
@@ -508,6 +528,1020 @@
     Some(request_id)
 }
 
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct A2APartBody {
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    text: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    url: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    data: Option<Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    metadata: Option<Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    filename: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    media_type: Option<String>,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct A2AMessageBody {
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    message_id: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    context_id: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    task_id: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    role: Option<String>,
+    #[serde(default)]
+    parts: Vec<A2APartBody>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    metadata: Option<Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    extensions: Option<Vec<String>>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    reference_task_ids: Option<Vec<String>>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct A2ASendMessageRequest {
+    message: A2AMessageBody,
+    #[serde(default)]
+    configuration: Option<Value>,
+    #[serde(default)]
+    metadata: Option<Value>,
+}
+
+#[derive(Debug, Default)]
+struct A2ATurnOutput {
+    assistant_text: String,
+    thinking_text: String,
+    usage: Option<TokenUsage>,
+    tools: Vec<Value>,
+}
+
+enum A2ATurnResult {
+    Completed(A2ATurnOutput),
+    Canceled,
+}
+
+#[derive(Debug)]
+struct A2ASendTarget {
+    task_id: String,
+    context_id: String,
+    history: Vec<Value>,
+    previous_task: Option<Value>,
+}
+
+fn is_a2a_endpoint(head: &RequestHead) -> bool {
+    if head.method == "OPTIONS" {
+        return head.path == "/.well-known/agent-card.json"
+            || head.path == "/message:send"
+            || head.path == "/tasks"
+            || head.path.starts_with("/tasks/");
+    }
+    matches!(
+        (head.method.as_str(), head.path.as_str()),
+        ("GET", "/.well-known/agent-card.json") | ("POST", "/message:send") | ("GET", "/tasks")
+    ) || (head.method == "GET" && a2a_task_id_from_get_path(&head.path).is_some())
+        || (head.method == "POST" && a2a_task_id_from_cancel_path(&head.path).is_some())
+}
+
+fn a2a_task_id_from_get_path(path: &str) -> Option<&str> {
+    let id = path.strip_prefix("/tasks/")?;
+    (!id.is_empty() && !id.contains('/') && !id.contains(':')).then_some(id)
+}
+
+fn a2a_task_id_from_cancel_path(path: &str) -> Option<&str> {
+    let id = path.strip_prefix("/tasks/")?.strip_suffix(":cancel")?;
+    (!id.is_empty() && !id.contains('/') && !id.contains(':')).then_some(id)
+}
+
+async fn handle_a2a_endpoint(
+    stream: &mut TcpStream,
+    initial: &mut Vec<u8>,
+    head: RequestHead,
+    state: &AppState,
+) -> Vec<u8> {
+    if head.method == "OPTIONS" {
+        return response(204, "text/plain; charset=utf-8", &[]);
+    }
+
+    if head.method == "GET" && head.path == "/.well-known/agent-card.json" {
+        return json_response(200, &a2a_agent_card(&head, &state.config));
+    }
+
+    let Some(auth) = auth_context(&head, &state.config) else {
+        return json_response(401, &serde_json::json!({ "error": "Unauthorized" }));
+    };
+
+    if head.method == "GET" && head.path == "/tasks" {
+        let tasks = state
+            .a2a_tasks
+            .lock()
+            .await
+            .values()
+            .filter(|task| a2a_task_visible_to_auth(task, &auth))
+            .cloned()
+            .collect::<Vec<_>>();
+        return json_response(200, &serde_json::json!({ "tasks": tasks }));
+    }
+
+    if head.method == "GET" {
+        if let Some(task_id) = a2a_task_id_from_get_path(&head.path) {
+            let tasks = state.a2a_tasks.lock().await;
+            return tasks.get(task_id).map_or_else(
+                || a2a_error_response(404, "TASK_NOT_FOUND", "A2A task not found"),
+                |task| {
+                    if a2a_task_visible_to_auth(task, &auth) {
+                        json_response(200, task)
+                    } else {
+                        a2a_error_response(404, "TASK_NOT_FOUND", "A2A task not found")
+                    }
+                },
+            );
+        }
+    }
+
+    if head.method == "POST" {
+        if let Some(task_id) = a2a_task_id_from_cancel_path(&head.path) {
+            return match cancel_a2a_task(state, task_id, &auth).await {
+                Ok(task) => json_response(200, &task),
+                Err(response) => response,
+            };
+        }
+    }
+
+    if head.method == "POST" && head.path == "/message:send" {
+        return handle_a2a_message_send(stream, initial, &head, state, &auth).await;
+    }
+
+    a2a_error_response(404, "NOT_FOUND", "A2A endpoint not found")
+}
+
+async fn cancel_a2a_task(
+    state: &AppState,
+    task_id: &str,
+    auth: &AuthContext,
+) -> Result<Value, Vec<u8>> {
+    let mut tasks = state.a2a_tasks.lock().await;
+    let Some(task) = tasks.get_mut(task_id) else {
+        return Err(a2a_error_response(
+            404,
+            "TASK_NOT_FOUND",
+            "A2A task not found",
+        ));
+    };
+    if !a2a_task_visible_to_auth(task, auth) {
+        return Err(a2a_error_response(
+            404,
+            "TASK_NOT_FOUND",
+            "A2A task not found",
+        ));
+    }
+    if a2a_task_is_terminal(task) {
+        return Err(a2a_error_response(
+            400,
+            "TASK_NOT_CANCELABLE",
+            "A2A task cannot be canceled from its current state",
+        ));
+    }
+    let context_id = task
+        .get("contextId")
+        .and_then(Value::as_str)
+        .unwrap_or("a2a")
+        .to_string();
+    task["status"] = serde_json::json!({
+        "state": "TASK_STATE_CANCELED",
+        "message": a2a_agent_message(&context_id, "Task canceled"),
+        "timestamp": now_rfc3339()
+    });
+    task["artifacts"] = serde_json::json!([]);
+    let task = task.clone();
+    prune_a2a_terminal_tasks(&mut tasks);
+    drop(tasks);
+
+    if let Some(sender) = state.a2a_cancel_senders.lock().await.remove(task_id) {
+        let _ = sender.send(true);
+    }
+
+    Ok(task)
+}
+
+fn a2a_task_status_state(task: &Value) -> Option<&str> {
+    task.get("status")
+        .and_then(|status| status.get("state"))
+        .and_then(Value::as_str)
+}
+
+fn a2a_task_status_timestamp(task: &Value) -> Option<&str> {
+    task.get("status")
+        .and_then(|status| status.get("timestamp"))
+        .and_then(Value::as_str)
+}
+
+fn a2a_task_is_terminal(task: &Value) -> bool {
+    matches!(
+        a2a_task_status_state(task),
+        Some(
+            "TASK_STATE_COMPLETED"
+                | "TASK_STATE_FAILED"
+                | "TASK_STATE_CANCELED"
+                | "TASK_STATE_REJECTED"
+        )
+    )
+}
+
+fn a2a_task_accepts_message(task: &Value) -> bool {
+    a2a_task_status_state(task) == Some("TASK_STATE_INPUT_REQUIRED")
+}
+
+fn a2a_task_owner_subject(task: &Value) -> Option<&str> {
+    task.get("metadata")
+        .and_then(|metadata| metadata.get("ownerSubject"))
+        .and_then(Value::as_str)
+}
+
+fn a2a_task_visible_to_auth(task: &Value, auth: &AuthContext) -> bool {
+    if auth.unrestricted {
+        return true;
+    }
+    auth.subject
+        .as_deref()
+        .is_some_and(|subject| a2a_task_owner_subject(task) == Some(subject))
+}
+
+async fn claim_a2a_send_task(
+    state: &AppState,
+    request: &A2ASendMessageRequest,
+    head: &RequestHead,
+    auth: &AuthContext,
+    metadata: Value,
+) -> Result<A2ASendTarget, Vec<u8>> {
+    let requested_task_id = request
+        .message
+        .task_id
+        .as_deref()
+        .map(str::trim)
+        .filter(|task_id| !task_id.is_empty());
+    let explicit_context_id = request
+        .message
+        .context_id
+        .as_deref()
+        .map(str::trim)
+        .filter(|context_id| !context_id.is_empty())
+        .map(str::to_string);
+
+    let mut tasks = state.a2a_tasks.lock().await;
+    let (task_id, context_id, mut history, previous_task) = if let Some(task_id) = requested_task_id
+    {
+        let Some(task) = tasks.get(task_id) else {
+            return Err(a2a_error_response(
+                404,
+                "TASK_NOT_FOUND",
+                "A2A task not found",
+            ));
+        };
+        if !a2a_task_visible_to_auth(task, auth) {
+            return Err(a2a_error_response(
+                404,
+                "TASK_NOT_FOUND",
+                "A2A task not found",
+            ));
+        }
+        if a2a_task_is_terminal(task) {
+            return Err(a2a_error_response(
+                400,
+                "UNSUPPORTED_OPERATION",
+                "A2A terminal tasks cannot accept more messages",
+            ));
+        }
+        if !a2a_task_accepts_message(task) {
+            return Err(a2a_error_response(
+                409,
+                "UNSUPPORTED_OPERATION",
+                "A2A task is not ready to accept another message",
+            ));
+        }
+
+        let task_context_id = task
+            .get("contextId")
+            .and_then(Value::as_str)
+            .map(str::trim)
+            .filter(|context_id| !context_id.is_empty())
+            .map(str::to_string);
+        if let (Some(message_context_id), Some(task_context_id)) =
+            (explicit_context_id.as_deref(), task_context_id.as_deref())
+        {
+            if message_context_id != task_context_id {
+                return Err(a2a_error_response(
+                    400,
+                    "INVALID_REQUEST",
+                    "A2A message contextId must match the referenced task",
+                ));
+            }
+        }
+        let context_id = explicit_context_id
+            .or(task_context_id)
+            .unwrap_or_else(|| a2a_context_id(request, head));
+        let history = task
+            .get("history")
+            .and_then(Value::as_array)
+            .cloned()
+            .unwrap_or_default();
+        (task_id.to_string(), context_id, history, Some(task.clone()))
+    } else {
+        (
+            generate_a2a_id("maestro-task"),
+            explicit_context_id.unwrap_or_else(|| a2a_context_id(request, head)),
+            Vec::new(),
+            None,
+        )
+    };
+    history.push(a2a_user_message_value(&request.message, &context_id));
+    let working_message = a2a_agent_message(&context_id, "Maestro is working on the A2A task.");
+    let task = a2a_task_value(
+        &task_id,
+        &context_id,
+        "TASK_STATE_WORKING",
+        working_message,
+        history.clone(),
+        Vec::new(),
+        metadata,
+    );
+    tasks.insert(task_id.clone(), task);
+    prune_a2a_terminal_tasks(&mut tasks);
+    Ok(A2ASendTarget {
+        task_id,
+        context_id,
+        history,
+        previous_task,
+    })
+}
+
+async fn rollback_a2a_send_claim(state: &AppState, task_id: &str, previous_task: Option<Value>) {
+    let mut tasks = state.a2a_tasks.lock().await;
+    if let Some(previous_task) = previous_task {
+        tasks.insert(task_id.to_string(), previous_task);
+    } else {
+        tasks.remove(task_id);
+    }
+}
+
+async fn a2a_canceled_task(state: &AppState, task_id: &str) -> Option<Value> {
+    state.a2a_tasks.lock().await.get(task_id).and_then(|task| {
+        (a2a_task_status_state(task) == Some("TASK_STATE_CANCELED")).then(|| task.clone())
+    })
+}
+
+async fn store_a2a_task_unless_canceled(state: &AppState, task_id: &str, task: Value) -> Value {
+    let mut tasks = state.a2a_tasks.lock().await;
+    if let Some(existing) = tasks.get(task_id) {
+        if a2a_task_status_state(existing) == Some("TASK_STATE_CANCELED") {
+            return existing.clone();
+        }
+    }
+    tasks.insert(task_id.to_string(), task.clone());
+    prune_a2a_terminal_tasks(&mut tasks);
+    task
+}
+
+fn prune_a2a_terminal_tasks(tasks: &mut HashMap<String, Value>) {
+    let mut terminal_tasks = tasks
+        .iter()
+        .filter(|(_, task)| a2a_task_is_terminal(task))
+        .map(|(task_id, task)| {
+            (
+                task_id.clone(),
+                a2a_task_status_timestamp(task)
+                    .unwrap_or_default()
+                    .to_string(),
+            )
+        })
+        .collect::<Vec<_>>();
+    if terminal_tasks.len() <= A2A_TERMINAL_TASK_STORE_LIMIT {
+        return;
+    }
+    terminal_tasks.sort_by(|(left_id, left_timestamp), (right_id, right_timestamp)| {
+        left_timestamp
+            .cmp(right_timestamp)
+            .then_with(|| left_id.cmp(right_id))
+    });
+    let overflow = terminal_tasks.len() - A2A_TERMINAL_TASK_STORE_LIMIT;
+    for (task_id, _) in terminal_tasks.into_iter().take(overflow) {
+        tasks.remove(&task_id);
+    }
+}
+
+async fn register_a2a_cancel_sender(
+    state: &AppState,
+    task_id: &str,
+    cancel_tx: A2ACancelSender,
+) -> Result<(), Vec<u8>> {
+    let mut senders = state.a2a_cancel_senders.lock().await;
+    if senders.contains_key(task_id) {
+        return Err(a2a_error_response(
+            409,
+            "UNSUPPORTED_OPERATION",
+            "A2A task is already running",
+        ));
+    }
+    senders.insert(task_id.to_string(), cancel_tx);
+    Ok(())
+}
+
+async fn handle_a2a_message_send(
+    stream: &mut TcpStream,
+    initial: &mut Vec<u8>,
+    head: &RequestHead,
+    state: &AppState,
+    auth: &AuthContext,
+) -> Vec<u8> {
+    let body = match read_request_body(stream, initial, head).await {
+        Ok(body) => body,
+        Err(error) => return a2a_error_response(400, "INVALID_REQUEST", &error),
+    };
+    let request: A2ASendMessageRequest = match serde_json::from_slice(&body) {
+        Ok(request) => request,
+        Err(error) => {
+            return a2a_error_response(
+                400,
+                "INVALID_REQUEST",
+                &format!("invalid A2A message request: {error}"),
+            );
+        }
+    };
+
+    let Some(prompt) = a2a_message_text(&request.message) else {
+        return a2a_error_response(
+            400,
+            "INVALID_REQUEST",
+            "A2A message must contain at least one text part",
+        );
+    };
+
+    let metadata = a2a_task_metadata(head, &request, auth);
+    let target = match claim_a2a_send_task(state, &request, head, auth, metadata.clone()).await {
+        Ok(target) => target,
+        Err(response) => return response,
+    };
+    let task_id = target.task_id;
+    let context_id = target.context_id;
+    let history = target.history;
+    let previous_task = target.previous_task;
+
+    let (cancel_tx, cancel_rx) = watch::channel(false);
+    if let Err(response) = register_a2a_cancel_sender(state, &task_id, cancel_tx).await {
+        rollback_a2a_send_claim(state, &task_id, previous_task).await;
+        return response;
+    }
+    if let Some(task) = a2a_canceled_task(state, &task_id).await {
+        state.a2a_cancel_senders.lock().await.remove(&task_id);
+        return json_response(200, &serde_json::json!({ "task": task }));
+    }
+    if a2a_return_immediately(&request) {
+        let accepted_message = a2a_agent_message(&context_id, "Maestro accepted the A2A task.");
+        let mut accepted_history = history.clone();
+        accepted_history.push(accepted_message.clone());
+        let task = a2a_task_value(
+            &task_id,
+            &context_id,
+            "TASK_STATE_WORKING",
+            accepted_message.clone(),
+            accepted_history.clone(),
+            Vec::new(),
+            metadata.clone(),
+        );
+        let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+        let state = state.clone();
+        tokio::spawn(async move {
+            let _ = complete_a2a_task(
+                &state,
+                prompt,
+                task_id,
+                context_id,
+                accepted_history,
+                metadata,
+                cancel_rx,
+            )
+            .await;
+        });
+        return json_response(200, &serde_json::json!({ "task": task }));
+    }
+
+    let task = complete_a2a_task(
+        state, prompt, task_id, context_id, history, metadata, cancel_rx,
+    )
+    .await;
+    json_response(200, &serde_json::json!({ "task": task }))
+}
+
+async fn complete_a2a_task(
+    state: &AppState,
+    prompt: String,
+    task_id: String,
+    context_id: String,
+    mut history: Vec<Value>,
+    mut metadata: Value,
+    cancel_rx: A2ACancelReceiver,
+) -> Value {
+    let turn = match run_a2a_native_turn(state, prompt, cancel_rx).await {
+        Ok(A2ATurnResult::Completed(turn)) => turn,
+        Ok(A2ATurnResult::Canceled) => {
+            let message = a2a_agent_message(&context_id, "Task canceled");
+            history.push(message.clone());
+            let task = a2a_task_value(
+                &task_id,
+                &context_id,
+                "TASK_STATE_CANCELED",
+                message,
+                history,
+                Vec::new(),
+                metadata,
+            );
+            let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+            state.a2a_cancel_senders.lock().await.remove(&task_id);
+            return task;
+        }
+        Err(error) => {
+            let message = a2a_agent_message(&context_id, &error);
+            history.push(message.clone());
+            let task = a2a_task_value(
+                &task_id,
+                &context_id,
+                "TASK_STATE_FAILED",
+                message.clone(),
+                history,
+                Vec::new(),
+                metadata,
+            );
+            let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+            state.a2a_cancel_senders.lock().await.remove(&task_id);
+            return task;
+        }
+    };
+
+    let assistant_text = if turn.assistant_text.trim().is_empty() {
+        "Maestro completed the A2A task without a text response.".to_string()
+    } else {
+        turn.assistant_text
+    };
+    let agent_message = a2a_agent_message(&context_id, &assistant_text);
+    if !turn.thinking_text.trim().is_empty() {
+        metadata["thinking"] = Value::String(turn.thinking_text);
+    }
+    if !turn.tools.is_empty() {
+        metadata["tools"] = Value::Array(turn.tools);
+    }
+    if let Some(usage) = turn.usage {
+        metadata["usage"] = serde_json::json!({
+            "input": usage.input_tokens,
+            "output": usage.output_tokens,
+            "cacheRead": usage.cache_read_tokens,
+            "cacheWrite": usage.cache_write_tokens,
+            "cost": usage.cost.unwrap_or(0.0)
+        });
+    }
+    let task = a2a_task_value(
+        &task_id,
+        &context_id,
+        "TASK_STATE_COMPLETED",
+        agent_message.clone(),
+        {
+            history.push(agent_message);
+            history
+        },
+        vec![serde_json::json!({
+            "artifactId": format!("{task_id}-assistant-response"),
+            "name": "assistant-response",
+            "parts": [{ "text": assistant_text, "mediaType": "text/plain" }]
+        })],
+        metadata,
+    );
+    let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+    state.a2a_cancel_senders.lock().await.remove(&task_id);
+    task
+}
+
+fn a2a_agent_card(head: &RequestHead, config: &Config) -> Value {
+    let base_url = a2a_public_base_url(head, config);
+    serde_json::json!({
+        "protocolVersion": A2A_PROTOCOL_VERSION,
+        "name": trimmed_env("MAESTRO_A2A_AGENT_NAME")
+            .unwrap_or_else(|| "Maestro Desktop Agent".to_string()),
+        "description": "Local Maestro Rust/TS TUI agent endpoint for A2A task delegation.",
+        "url": base_url,
+        "preferredTransport": "HTTP+JSON",
+        "supportedInterfaces": [{
+            "url": base_url,
+            "protocolBinding": "HTTP+JSON",
+            "protocolVersion": A2A_PROTOCOL_VERSION
+        }],
+        "provider": {
+            "organization": "EvalOps",
+            "url": "https://evalops.com"
+        },
+        "version": env!("CARGO_PKG_VERSION"),
+        "capabilities": {
+            "streaming": false,
+            "pushNotifications": false,
+            "extendedAgentCard": false
+        },
+        "defaultInputModes": ["text/plain"],
+        "defaultOutputModes": ["text/plain", "application/json"],
+        "skills": [{
+            "id": "maestro-tui-turn",
+            "name": "Maestro TUI turn",
+            "description": "Run a prompt through the local Maestro native TUI agent runner.",
+            "tags": ["maestro", "tui", "codex", "a2a"],
+            "examples": [
+                "Review the current workspace and summarize the next highest leverage action."
+            ],
+            "inputModes": ["text/plain"],
+            "outputModes": ["text/plain", "application/json"]
+        }]
+    })
+}
+
+fn a2a_public_base_url(_head: &RequestHead, config: &Config) -> String {
+    if let Some(url) =
+        trimmed_env("MAESTRO_A2A_PUBLIC_URL").or_else(|| trimmed_env("MAESTRO_CONTROL_PUBLIC_URL"))
+    {
+        return url.trim_end_matches('/').to_string();
+    }
+    let host = if config.listen_host == "0.0.0.0" || config.listen_host == "::" {
+        "127.0.0.1"
+    } else {
+        config.listen_host.as_str()
+    };
+    format!("http://{host}:{}", config.listen_port)
+}
+
+fn a2a_message_text(message: &A2AMessageBody) -> Option<String> {
+    let text = message
+        .parts
+        .iter()
+        .filter_map(|part| part.text.as_deref())
+        .map(str::trim)
+        .filter(|part| !part.is_empty())
+        .collect::<Vec<_>>()
+        .join("\n\n");
+    (!text.is_empty()).then_some(text)
+}
+
+fn trimmed_non_empty_string(value: &str) -> Option<String> {
+    let value = value.trim();
+    (!value.is_empty()).then(|| value.to_string())
+}
+
+fn a2a_context_id(request: &A2ASendMessageRequest, head: &RequestHead) -> String {
+    request
+        .message
+        .context_id
+        .as_deref()
+        .and_then(trimmed_non_empty_string)
+        .or_else(|| {
+            request
+                .message
+                .metadata
+                .as_ref()
+                .and_then(|metadata| metadata.get("sessionId").and_then(Value::as_str))
+                .and_then(trimmed_non_empty_string)
+        })
+        .or_else(|| {
+            head.headers
+                .get("x-evalops-session-id")
+                .and_then(|value| trimmed_non_empty_string(value))
+        })
+        .or_else(|| {
+            head.headers
+                .get("x-maestro-session-id")
+                .and_then(|value| trimmed_non_empty_string(value))
+        })
+        .unwrap_or_else(|| generate_a2a_id("maestro-context"))
+}
+
+fn a2a_user_message_value(message: &A2AMessageBody, context_id: &str) -> Value {
+    let mut value = serde_json::to_value(message).unwrap_or_else(|_| serde_json::json!({}));
+    if let Value::Object(object) = &mut value {
+        object
+            .entry("messageId")
+            .or_insert_with(|| Value::String(generate_a2a_id("maestro-message")));
+        object.insert(
+            "contextId".to_string(),
+            Value::String(context_id.to_string()),
+        );
... diff truncated: showing 800 of 2783 lines

You can send follow-ups to the cloud agent here.

Comment thread packages/control-plane-rs/src/main.rs Outdated
Comment thread scripts/codex-a2a-bridge.py Outdated
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: deca429596

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/control-plane-rs/src/main.rs
Comment thread packages/control-plane-rs/src/main.rs Outdated
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 970196e4bf

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/control-plane-rs/src/main.rs Outdated
Comment thread packages/control-plane-rs/src/main.rs
Comment thread scripts/codex-a2a-bridge.py Outdated
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6e2346f3da

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/control-plane-rs/src/main.rs Outdated
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: IPv6 host produces malformed URL without port
    • IPv6 public hosts are now bracketed and still receive the configured port unless an explicit bracketed port is already present.
Preview (f0be74879f)
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -104,6 +104,8 @@
     "smoke": "node scripts/smoke-cli.js",
     "smoke:local-e2e": "node scripts/smoke-cli.js",
     "smoke:event-bus": "tsx scripts/smoke-maestro-event-bus.ts",
+    "smoke:a2a-local": "tsx scripts/smoke-maestro-a2a-local.ts",
+    "a2a:codex-bridge": "python3 scripts/codex-a2a-bridge.py",
     "smoke:headless": "node scripts/smoke-headless.js",
     "headless:responsiveness": "node scripts/headless-responsiveness-harness.js",
     "smoke:pack": "node scripts/smoke-packed-cli.js",

diff --git a/packages/control-plane-rs/src/http.rs b/packages/control-plane-rs/src/http.rs
--- a/packages/control-plane-rs/src/http.rs
+++ b/packages/control-plane-rs/src/http.rs
@@ -11,7 +11,7 @@
 
 const MAX_HEADER_BYTES: usize = 64 * 1024;
 pub(crate) const MAX_JSON_BODY_BYTES: usize = 32 * 1024 * 1024;
-const CORS_ALLOW_HEADERS: &str = "authorization,content-type,x-composer-artifact-access,x-composer-api-key,x-composer-approval-mode,x-composer-client,x-composer-client-tools,x-composer-csrf,x-composer-agent-id,x-composer-slim-events,x-composer-workspace,x-composer-workspace-id,x-maestro-artifact-access,x-maestro-api-key,x-maestro-approval-mode,x-maestro-agent-id,x-maestro-client,x-maestro-client-tools,x-maestro-csrf,x-maestro-slim-events,x-maestro-workspace,x-maestro-workspace-id,x-csrf-token,x-xsrf-token";
+const CORS_ALLOW_HEADERS: &str = "authorization,content-type,a2a-version,a2a-extensions,traceparent,tracestate,x-organization-id,x-evalops-agent-id,x-evalops-actor-id,x-evalops-session-id,x-evalops-workspace-id,x-composer-artifact-access,x-composer-api-key,x-composer-approval-mode,x-composer-client,x-composer-client-tools,x-composer-csrf,x-composer-agent-id,x-composer-slim-events,x-composer-workspace,x-composer-workspace-id,x-maestro-artifact-access,x-maestro-api-key,x-maestro-approval-mode,x-maestro-agent-id,x-maestro-client,x-maestro-client-tools,x-maestro-csrf,x-maestro-slim-events,x-maestro-workspace,x-maestro-workspace-id,x-csrf-token,x-xsrf-token";
 
 #[derive(Debug)]
 pub(crate) struct RequestHead {

diff --git a/packages/control-plane-rs/src/main.rs b/packages/control-plane-rs/src/main.rs
--- a/packages/control-plane-rs/src/main.rs
+++ b/packages/control-plane-rs/src/main.rs
@@ -17,7 +17,7 @@
 use tokio::io::{AsyncReadExt, AsyncWriteExt};
 use tokio::net::{TcpListener, TcpStream};
 use tokio::process::Command;
-use tokio::sync::{mpsc, Mutex};
+use tokio::sync::{mpsc, watch, Mutex};
 
 mod auth;
 mod http;
@@ -49,9 +49,15 @@
 const DEFAULT_EXTRACT_MAX_CHARS: usize = 200_000;
 const MAX_EXTRACT_INPUT_BYTES: usize = 50 * 1024 * 1024;
 const MAX_PROJECT_ONBOARDING_IMPRESSIONS: u8 = 4;
+const A2A_PROTOCOL_VERSION: &str = "1.0";
+const A2A_DEFAULT_TURN_TIMEOUT_MS: u64 = 180_000;
+const A2A_DEFAULT_RESPONSE_END_SETTLE_MS: u64 = 250;
+const A2A_TERMINAL_TASK_STORE_LIMIT: usize = 128;
 static ATTACHMENT_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
 static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
 type PendingToolResponseSender = mpsc::UnboundedSender<(String, bool, Option<ToolResult>)>;
+type A2ACancelSender = watch::Sender<bool>;
+type A2ACancelReceiver = watch::Receiver<bool>;
 #[derive(Debug, Clone)]
 struct Config {
     listen_host: String,
@@ -151,6 +157,8 @@
     shared_sessions: Arc<Mutex<HashMap<String, SharedSessionGrant>>>,
     approval_modes: Arc<Mutex<HashMap<String, String>>>,
     pending_tool_responses: Arc<Mutex<HashMap<String, PendingToolResponseSender>>>,
+    a2a_tasks: Arc<Mutex<HashMap<String, Value>>>,
+    a2a_cancel_senders: Arc<Mutex<HashMap<String, A2ACancelSender>>>,
 }
 
 #[derive(Clone, Debug, Default, Serialize)]
@@ -295,6 +303,8 @@
         shared_sessions: Arc::new(Mutex::new(shared_sessions)),
         approval_modes: Arc::new(Mutex::new(HashMap::new())),
         pending_tool_responses: Arc::new(Mutex::new(HashMap::new())),
+        a2a_tasks: Arc::new(Mutex::new(HashMap::new())),
+        a2a_cancel_senders: Arc::new(Mutex::new(HashMap::new())),
     };
 
     loop {
@@ -329,6 +339,16 @@
             return handle_chat_endpoint(stream, initial, head, state).await;
         }
 
+        if is_a2a_endpoint(&head) {
+            let response = handle_a2a_endpoint(&mut stream, &mut initial, head, &state).await;
+            stream
+                .write_all(&response)
+                .await
+                .map_err(|error| error.to_string())?;
+            let _ = stream.shutdown().await;
+            return Ok(());
+        }
+
         if is_local_endpoint(&head) {
             let response = handle_local_endpoint(&mut stream, &mut initial, head, &state).await;
             stream
@@ -508,6 +528,1062 @@
     Some(request_id)
 }
 
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct A2APartBody {
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    text: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    url: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    data: Option<Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    metadata: Option<Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    filename: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    media_type: Option<String>,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct A2AMessageBody {
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    message_id: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    context_id: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    task_id: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    role: Option<String>,
+    #[serde(default)]
+    parts: Vec<A2APartBody>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    metadata: Option<Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    extensions: Option<Vec<String>>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    reference_task_ids: Option<Vec<String>>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct A2ASendMessageRequest {
+    message: A2AMessageBody,
+    #[serde(default)]
+    configuration: Option<Value>,
+    #[serde(default)]
+    metadata: Option<Value>,
+}
+
+#[derive(Debug, Default)]
+struct A2ATurnOutput {
+    assistant_text: String,
+    thinking_text: String,
+    usage: Option<TokenUsage>,
+    tools: Vec<Value>,
+}
+
+enum A2ATurnResult {
+    Completed(A2ATurnOutput),
+    Canceled,
+}
+
+#[derive(Debug)]
+struct A2ASendTarget {
+    task_id: String,
+    context_id: String,
+    history: Vec<Value>,
+    previous_task: Option<Value>,
+    metadata: Value,
+}
+
+fn is_a2a_endpoint(head: &RequestHead) -> bool {
+    if head.method == "OPTIONS" {
+        return head.path == "/.well-known/agent-card.json"
+            || head.path == "/message:send"
+            || head.path == "/tasks"
+            || head.path.starts_with("/tasks/");
+    }
+    matches!(
+        (head.method.as_str(), head.path.as_str()),
+        ("GET", "/.well-known/agent-card.json") | ("POST", "/message:send") | ("GET", "/tasks")
+    ) || (head.method == "GET" && a2a_task_id_from_get_path(&head.path).is_some())
+        || (head.method == "POST" && a2a_task_id_from_cancel_path(&head.path).is_some())
+}
+
+fn a2a_task_id_from_get_path(path: &str) -> Option<&str> {
+    let id = path.strip_prefix("/tasks/")?;
+    (!id.is_empty() && !id.contains('/') && !id.contains(':')).then_some(id)
+}
+
+fn a2a_task_id_from_cancel_path(path: &str) -> Option<&str> {
+    let id = path.strip_prefix("/tasks/")?.strip_suffix(":cancel")?;
+    (!id.is_empty() && !id.contains('/') && !id.contains(':')).then_some(id)
+}
+
+async fn handle_a2a_endpoint(
+    stream: &mut TcpStream,
+    initial: &mut Vec<u8>,
+    head: RequestHead,
+    state: &AppState,
+) -> Vec<u8> {
+    if head.method == "OPTIONS" {
+        return response(204, "text/plain; charset=utf-8", &[]);
+    }
+
+    if head.method == "GET" && head.path == "/.well-known/agent-card.json" {
+        return json_response(200, &a2a_agent_card(&head, &state.config));
+    }
+
+    let Some(auth) = auth_context(&head, &state.config) else {
+        return json_response(401, &serde_json::json!({ "error": "Unauthorized" }));
+    };
+
+    if head.method == "GET" && head.path == "/tasks" {
+        let tasks = state
+            .a2a_tasks
+            .lock()
+            .await
+            .values()
+            .filter(|task| a2a_task_visible_to_auth(task, &auth))
+            .cloned()
+            .collect::<Vec<_>>();
+        return json_response(200, &serde_json::json!({ "tasks": tasks }));
+    }
+
+    if head.method == "GET" {
+        if let Some(task_id) = a2a_task_id_from_get_path(&head.path) {
+            let tasks = state.a2a_tasks.lock().await;
+            return tasks.get(task_id).map_or_else(
+                || a2a_error_response(404, "TASK_NOT_FOUND", "A2A task not found"),
+                |task| {
+                    if a2a_task_visible_to_auth(task, &auth) {
+                        json_response(200, task)
+                    } else {
+                        a2a_error_response(404, "TASK_NOT_FOUND", "A2A task not found")
+                    }
+                },
+            );
+        }
+    }
+
+    if head.method == "POST" {
+        if let Some(task_id) = a2a_task_id_from_cancel_path(&head.path) {
+            return match cancel_a2a_task(state, task_id, &auth).await {
+                Ok(task) => json_response(200, &task),
+                Err(response) => response,
+            };
+        }
+    }
+
+    if head.method == "POST" && head.path == "/message:send" {
+        return handle_a2a_message_send(stream, initial, &head, state, &auth).await;
+    }
+
+    a2a_error_response(404, "NOT_FOUND", "A2A endpoint not found")
+}
+
+async fn cancel_a2a_task(
+    state: &AppState,
+    task_id: &str,
+    auth: &AuthContext,
+) -> Result<Value, Vec<u8>> {
+    let mut tasks = state.a2a_tasks.lock().await;
+    let Some(task) = tasks.get_mut(task_id) else {
+        return Err(a2a_error_response(
+            404,
+            "TASK_NOT_FOUND",
+            "A2A task not found",
+        ));
+    };
+    if !a2a_task_visible_to_auth(task, auth) {
+        return Err(a2a_error_response(
+            404,
+            "TASK_NOT_FOUND",
+            "A2A task not found",
+        ));
+    }
+    if a2a_task_is_terminal(task) {
+        return Err(a2a_error_response(
+            400,
+            "TASK_NOT_CANCELABLE",
+            "A2A task cannot be canceled from its current state",
+        ));
+    }
+    let context_id = task
+        .get("contextId")
+        .and_then(Value::as_str)
+        .unwrap_or("a2a")
+        .to_string();
+    task["status"] = serde_json::json!({
+        "state": "TASK_STATE_CANCELED",
+        "message": a2a_agent_message(&context_id, "Task canceled"),
+        "timestamp": now_rfc3339()
+    });
+    task["artifacts"] = serde_json::json!([]);
+    let task = task.clone();
+    prune_a2a_terminal_tasks(&mut tasks);
+    drop(tasks);
+
+    if let Some(sender) = state.a2a_cancel_senders.lock().await.remove(task_id) {
+        let _ = sender.send(true);
+    }
+
+    Ok(task)
+}
+
+fn a2a_task_status_state(task: &Value) -> Option<&str> {
+    task.get("status")
+        .and_then(|status| status.get("state"))
+        .and_then(Value::as_str)
+}
+
+fn a2a_task_status_timestamp(task: &Value) -> Option<&str> {
+    task.get("status")
+        .and_then(|status| status.get("timestamp"))
+        .and_then(Value::as_str)
+}
+
+fn a2a_task_is_terminal(task: &Value) -> bool {
+    matches!(
+        a2a_task_status_state(task),
+        Some(
+            "TASK_STATE_COMPLETED"
+                | "TASK_STATE_FAILED"
+                | "TASK_STATE_CANCELED"
+                | "TASK_STATE_REJECTED"
+        )
+    )
+}
+
+fn a2a_task_accepts_message(task: &Value) -> bool {
+    a2a_task_status_state(task) == Some("TASK_STATE_INPUT_REQUIRED")
+}
+
+fn a2a_task_owner_subject(task: &Value) -> Option<&str> {
+    task.get("metadata")
+        .and_then(|metadata| metadata.get("ownerSubject"))
+        .and_then(Value::as_str)
+}
+
+fn a2a_task_visible_to_auth(task: &Value, auth: &AuthContext) -> bool {
+    if auth.unrestricted {
+        return true;
+    }
+    auth.subject
+        .as_deref()
+        .is_some_and(|subject| a2a_task_owner_subject(task) == Some(subject))
+}
+
+async fn claim_a2a_send_task(
+    state: &AppState,
+    request: &A2ASendMessageRequest,
+    head: &RequestHead,
+    auth: &AuthContext,
+    metadata: Value,
+) -> Result<A2ASendTarget, Vec<u8>> {
+    let requested_task_id = request
+        .message
+        .task_id
+        .as_deref()
+        .map(str::trim)
+        .filter(|task_id| !task_id.is_empty());
+    let explicit_context_id = request
+        .message
+        .context_id
+        .as_deref()
+        .map(str::trim)
+        .filter(|context_id| !context_id.is_empty())
+        .map(str::to_string);
+
+    let mut tasks = state.a2a_tasks.lock().await;
+    let (task_id, context_id, mut history, previous_task, task_metadata) =
+        if let Some(task_id) = requested_task_id {
+            let Some(task) = tasks.get(task_id) else {
+                return Err(a2a_error_response(
+                    404,
+                    "TASK_NOT_FOUND",
+                    "A2A task not found",
+                ));
+            };
+            if !a2a_task_visible_to_auth(task, auth) {
+                return Err(a2a_error_response(
+                    404,
+                    "TASK_NOT_FOUND",
+                    "A2A task not found",
+                ));
+            }
+            if a2a_task_is_terminal(task) {
+                return Err(a2a_error_response(
+                    400,
+                    "UNSUPPORTED_OPERATION",
+                    "A2A terminal tasks cannot accept more messages",
+                ));
+            }
+            if !a2a_task_accepts_message(task) {
+                return Err(a2a_error_response(
+                    409,
+                    "UNSUPPORTED_OPERATION",
+                    "A2A task is not ready to accept another message",
+                ));
+            }
+
+            let task_context_id = task
+                .get("contextId")
+                .and_then(Value::as_str)
+                .map(str::trim)
+                .filter(|context_id| !context_id.is_empty())
+                .map(str::to_string);
+            if let (Some(message_context_id), Some(task_context_id)) =
+                (explicit_context_id.as_deref(), task_context_id.as_deref())
+            {
+                if message_context_id != task_context_id {
+                    return Err(a2a_error_response(
+                        400,
+                        "INVALID_REQUEST",
+                        "A2A message contextId must match the referenced task",
+                    ));
+                }
+            }
+            let context_id = explicit_context_id
+                .or(task_context_id)
+                .unwrap_or_else(|| a2a_context_id(request, head));
+            let history = task
+                .get("history")
+                .and_then(Value::as_array)
+                .cloned()
+                .unwrap_or_default();
+            (
+                task_id.to_string(),
+                context_id,
+                history,
+                Some(task.clone()),
+                a2a_merge_task_metadata(task, metadata),
+            )
+        } else {
+            (
+                generate_a2a_id("maestro-task"),
+                explicit_context_id.unwrap_or_else(|| a2a_context_id(request, head)),
+                Vec::new(),
+                None,
+                metadata,
+            )
+        };
+    history.push(a2a_user_message_value(&request.message, &context_id));
+    let working_message = a2a_agent_message(&context_id, "Maestro is working on the A2A task.");
+    let task = a2a_task_value(
+        &task_id,
+        &context_id,
+        "TASK_STATE_WORKING",
+        working_message,
+        history.clone(),
+        Vec::new(),
+        task_metadata.clone(),
+    );
+    tasks.insert(task_id.clone(), task);
+    prune_a2a_terminal_tasks(&mut tasks);
+    Ok(A2ASendTarget {
+        task_id,
+        context_id,
+        history,
+        previous_task,
+        metadata: task_metadata,
+    })
+}
+
+fn a2a_merge_task_metadata(existing_task: &Value, metadata: Value) -> Value {
+    let mut merged = existing_task
+        .get("metadata")
+        .and_then(Value::as_object)
+        .cloned()
+        .unwrap_or_default();
+    if let Value::Object(metadata) = metadata {
+        for (key, value) in metadata {
+            merged.insert(key, value);
+        }
+    }
+    Value::Object(merged)
+}
+
+async fn rollback_a2a_send_claim(state: &AppState, task_id: &str, previous_task: Option<Value>) {
+    let mut tasks = state.a2a_tasks.lock().await;
+    if let Some(previous_task) = previous_task {
+        tasks.insert(task_id.to_string(), previous_task);
+    } else {
+        tasks.remove(task_id);
+    }
+}
+
+async fn a2a_canceled_task(state: &AppState, task_id: &str) -> Option<Value> {
+    state.a2a_tasks.lock().await.get(task_id).and_then(|task| {
+        (a2a_task_status_state(task) == Some("TASK_STATE_CANCELED")).then(|| task.clone())
+    })
+}
+
+async fn store_a2a_task_unless_canceled(state: &AppState, task_id: &str, task: Value) -> Value {
+    let mut tasks = state.a2a_tasks.lock().await;
+    if let Some(existing) = tasks.get(task_id) {
+        if a2a_task_status_state(existing) == Some("TASK_STATE_CANCELED") {
+            return existing.clone();
+        }
+    }
+    tasks.insert(task_id.to_string(), task.clone());
+    prune_a2a_terminal_tasks(&mut tasks);
+    task
+}
+
+fn prune_a2a_terminal_tasks(tasks: &mut HashMap<String, Value>) {
+    let mut terminal_tasks = tasks
+        .iter()
+        .filter(|(_, task)| a2a_task_is_terminal(task))
+        .map(|(task_id, task)| {
+            (
+                task_id.clone(),
+                a2a_task_status_timestamp(task)
+                    .unwrap_or_default()
+                    .to_string(),
+            )
+        })
+        .collect::<Vec<_>>();
+    if terminal_tasks.len() <= A2A_TERMINAL_TASK_STORE_LIMIT {
+        return;
+    }
+    terminal_tasks.sort_by(|(left_id, left_timestamp), (right_id, right_timestamp)| {
+        left_timestamp
+            .cmp(right_timestamp)
+            .then_with(|| left_id.cmp(right_id))
+    });
+    let overflow = terminal_tasks.len() - A2A_TERMINAL_TASK_STORE_LIMIT;
+    for (task_id, _) in terminal_tasks.into_iter().take(overflow) {
+        tasks.remove(&task_id);
+    }
+}
+
+async fn register_a2a_cancel_sender(
+    state: &AppState,
+    task_id: &str,
+    cancel_tx: A2ACancelSender,
+) -> Result<(), Vec<u8>> {
+    let mut senders = state.a2a_cancel_senders.lock().await;
+    if senders.contains_key(task_id) {
+        return Err(a2a_error_response(
+            409,
+            "UNSUPPORTED_OPERATION",
+            "A2A task is already running",
+        ));
+    }
+    senders.insert(task_id.to_string(), cancel_tx);
+    Ok(())
+}
+
+async fn handle_a2a_message_send(
+    stream: &mut TcpStream,
+    initial: &mut Vec<u8>,
+    head: &RequestHead,
+    state: &AppState,
+    auth: &AuthContext,
+) -> Vec<u8> {
+    let body = match read_request_body(stream, initial, head).await {
+        Ok(body) => body,
+        Err(error) => return a2a_error_response(400, "INVALID_REQUEST", &error),
+    };
+    let request: A2ASendMessageRequest = match serde_json::from_slice(&body) {
+        Ok(request) => request,
+        Err(error) => {
+            return a2a_error_response(
+                400,
+                "INVALID_REQUEST",
+                &format!("invalid A2A message request: {error}"),
+            );
+        }
+    };
+
+    let Some(prompt) = a2a_message_text(&request.message) else {
+        return a2a_error_response(
+            400,
+            "INVALID_REQUEST",
+            "A2A message must contain at least one text part",
+        );
+    };
+
+    let metadata = a2a_task_metadata(head, &request, auth);
+    let target = match claim_a2a_send_task(state, &request, head, auth, metadata).await {
+        Ok(target) => target,
+        Err(response) => return response,
+    };
+    let task_id = target.task_id;
+    let context_id = target.context_id;
+    let history = target.history;
+    let previous_task = target.previous_task;
+    let metadata = target.metadata;
+
+    let (cancel_tx, cancel_rx) = watch::channel(false);
+    if let Err(response) = register_a2a_cancel_sender(state, &task_id, cancel_tx).await {
+        rollback_a2a_send_claim(state, &task_id, previous_task).await;
+        return response;
+    }
+    if let Some(task) = a2a_canceled_task(state, &task_id).await {
+        state.a2a_cancel_senders.lock().await.remove(&task_id);
+        return json_response(200, &serde_json::json!({ "task": task }));
+    }
+    if a2a_return_immediately(&request) {
+        let accepted_message = a2a_agent_message(&context_id, "Maestro accepted the A2A task.");
+        let mut accepted_history = history.clone();
+        accepted_history.push(accepted_message.clone());
+        let task = a2a_task_value(
+            &task_id,
+            &context_id,
+            "TASK_STATE_WORKING",
+            accepted_message.clone(),
+            accepted_history.clone(),
+            Vec::new(),
+            metadata.clone(),
+        );
+        let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+        let state = state.clone();
+        tokio::spawn(async move {
+            let _ = complete_a2a_task(
+                &state,
+                prompt,
+                task_id,
+                context_id,
+                accepted_history,
+                metadata,
+                cancel_rx,
+            )
+            .await;
+        });
+        return json_response(200, &serde_json::json!({ "task": task }));
+    }
+
+    let task = complete_a2a_task(
+        state, prompt, task_id, context_id, history, metadata, cancel_rx,
+    )
+    .await;
+    json_response(200, &serde_json::json!({ "task": task }))
+}
+
+async fn complete_a2a_task(
+    state: &AppState,
+    prompt: String,
+    task_id: String,
+    context_id: String,
+    mut history: Vec<Value>,
+    mut metadata: Value,
+    cancel_rx: A2ACancelReceiver,
+) -> Value {
+    let turn = match run_a2a_native_turn(state, prompt, cancel_rx).await {
+        Ok(A2ATurnResult::Completed(turn)) => turn,
+        Ok(A2ATurnResult::Canceled) => {
+            let message = a2a_agent_message(&context_id, "Task canceled");
+            history.push(message.clone());
+            let task = a2a_task_value(
+                &task_id,
+                &context_id,
+                "TASK_STATE_CANCELED",
+                message,
+                history,
+                Vec::new(),
+                metadata,
+            );
+            let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+            state.a2a_cancel_senders.lock().await.remove(&task_id);
+            return task;
+        }
+        Err(error) => {
+            let message = a2a_agent_message(&context_id, &error);
+            history.push(message.clone());
+            let task = a2a_task_value(
+                &task_id,
+                &context_id,
+                "TASK_STATE_FAILED",
+                message.clone(),
+                history,
+                Vec::new(),
+                metadata,
+            );
+            let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+            state.a2a_cancel_senders.lock().await.remove(&task_id);
+            return task;
+        }
+    };
+
+    let assistant_text = if turn.assistant_text.trim().is_empty() {
+        "Maestro completed the A2A task without a text response.".to_string()
+    } else {
+        turn.assistant_text
+    };
+    let agent_message = a2a_agent_message(&context_id, &assistant_text);
+    if !turn.thinking_text.trim().is_empty() {
+        metadata["thinking"] = Value::String(turn.thinking_text);
+    }
+    if !turn.tools.is_empty() {
+        metadata["tools"] = Value::Array(turn.tools);
+    }
+    if let Some(usage) = turn.usage {
+        metadata["usage"] = serde_json::json!({
+            "input": usage.input_tokens,
+            "output": usage.output_tokens,
+            "cacheRead": usage.cache_read_tokens,
+            "cacheWrite": usage.cache_write_tokens,
+            "cost": usage.cost.unwrap_or(0.0)
+        });
+    }
+    let task = a2a_task_value(
+        &task_id,
+        &context_id,
+        "TASK_STATE_COMPLETED",
+        agent_message.clone(),
+        {
+            history.push(agent_message);
+            history
+        },
+        vec![serde_json::json!({
+            "artifactId": format!("{task_id}-assistant-response"),
+            "name": "assistant-response",
+            "parts": [{ "text": assistant_text, "mediaType": "text/plain" }]
+        })],
+        metadata,
+    );
+    let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+    state.a2a_cancel_senders.lock().await.remove(&task_id);
+    task
+}
+
+fn a2a_agent_card(head: &RequestHead, config: &Config) -> Value {
+    let base_url = a2a_public_base_url(head, config);
+    serde_json::json!({
+        "protocolVersion": A2A_PROTOCOL_VERSION,
+        "name": trimmed_env("MAESTRO_A2A_AGENT_NAME")
+            .unwrap_or_else(|| "Maestro Desktop Agent".to_string()),
+        "description": "Local Maestro Rust/TS TUI agent endpoint for A2A task delegation.",
+        "url": base_url,
+        "preferredTransport": "HTTP+JSON",
+        "supportedInterfaces": [{
+            "url": base_url,
+            "protocolBinding": "HTTP+JSON",
+            "protocolVersion": A2A_PROTOCOL_VERSION
+        }],
+        "provider": {
+            "organization": "EvalOps",
+            "url": "https://evalops.com"
+        },
+        "version": env!("CARGO_PKG_VERSION"),
+        "capabilities": {
+            "streaming": false,
+            "pushNotifications": false,
+            "extendedAgentCard": false
+        },
+        "defaultInputModes": ["text/plain"],
+        "defaultOutputModes": ["text/plain", "application/json"],
+        "skills": [{
+            "id": "maestro-tui-turn",
+            "name": "Maestro TUI turn",
+            "description": "Run a prompt through the local Maestro native TUI agent runner.",
+            "tags": ["maestro", "tui", "codex", "a2a"],
+            "examples": [
+                "Review the current workspace and summarize the next highest leverage action."
+            ],
+            "inputModes": ["text/plain"],
+            "outputModes": ["text/plain", "application/json"]
+        }]
+    })
+}
+
+fn a2a_public_base_url(_head: &RequestHead, config: &Config) -> String {
+    if let Some(url) =
+        trimmed_env("MAESTRO_A2A_PUBLIC_URL").or_else(|| trimmed_env("MAESTRO_CONTROL_PUBLIC_URL"))
+    {
+        return url.trim_end_matches('/').to_string();
+    }
+    let host = if let Some(host) = trimmed_env("MAESTRO_A2A_PUBLIC_HOST")
+        .or_else(|| trimmed_env("MAESTRO_CONTROL_PUBLIC_HOST"))
+    {
+        host
+    } else if config.listen_host == "0.0.0.0" || config.listen_host == "::" {
+        trimmed_env("HOSTNAME")
+            .or_else(|| trimmed_env("COMPUTERNAME"))
+            .unwrap_or_else(|| "127.0.0.1".to_string())
+    } else {
+        config.listen_host.clone()
+    };
+    if host.starts_with('[') {
+        if host.contains("]:") {
+            format!("http://{host}")
+        } else {
+            format!("http://{host}:{}", config.listen_port)
+        }
+    } else if host.matches(':').count() > 1 {
+        format!("http://[{host}]:{}", config.listen_port)
+    } else if host.contains(':') {
+        format!("http://{host}")
+    } else {
+        format!("http://{host}:{}", config.listen_port)
+    }
+}
+
+fn a2a_message_text(message: &A2AMessageBody) -> Option<String> {
+    let text = message
+        .parts
+        .iter()
+        .filter_map(|part| part.text.as_deref())
+        .map(str::trim)
+        .filter(|part| !part.is_empty())
+        .collect::<Vec<_>>()
+        .join("\n\n");
+    (!text.is_empty()).then_some(text)
+}
+
... diff truncated: showing 800 of 2976 lines

You can send follow-ups to the cloud agent here.

Comment thread packages/control-plane-rs/src/main.rs
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f0be74879f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/control-plane-rs/src/main.rs Outdated
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: A2A endpoints bypass CSRF validation for state-changing operations
    • A2A POST handlers now invoke CSRF validation and the CSRF matcher covers /message:send plus task-cancel routes with regression tests.
  • ✅ Fixed: Unused function update_tool_metadata_status duplicates finish_tool_metadata pattern
    • A2A native turn handling now marks tool calls as running on ToolStart before finish_tool_metadata records completion or error.
Preview (7b34f77157)
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -104,6 +104,8 @@
     "smoke": "node scripts/smoke-cli.js",
     "smoke:local-e2e": "node scripts/smoke-cli.js",
     "smoke:event-bus": "tsx scripts/smoke-maestro-event-bus.ts",
+    "smoke:a2a-local": "tsx scripts/smoke-maestro-a2a-local.ts",
+    "a2a:codex-bridge": "python3 scripts/codex-a2a-bridge.py",
     "smoke:headless": "node scripts/smoke-headless.js",
     "headless:responsiveness": "node scripts/headless-responsiveness-harness.js",
     "smoke:pack": "node scripts/smoke-packed-cli.js",

diff --git a/packages/control-plane-rs/src/auth.rs b/packages/control-plane-rs/src/auth.rs
--- a/packages/control-plane-rs/src/auth.rs
+++ b/packages/control-plane-rs/src/auth.rs
@@ -185,9 +185,23 @@
 }
 
 pub(crate) fn csrf_applies(head: &RequestHead) -> bool {
-    head.path.starts_with("/api/") && !matches!(head.method.as_str(), "GET" | "HEAD" | "OPTIONS")
+    if matches!(head.method.as_str(), "GET" | "HEAD" | "OPTIONS") {
+        return false;
+    }
+
+    head.path.starts_with("/api/") || csrf_applies_to_a2a_path(&head.path)
 }
 
+fn csrf_applies_to_a2a_path(path: &str) -> bool {
+    path == "/message:send"
+        || path
+            .strip_prefix("/tasks/")
+            .and_then(|value| value.strip_suffix(":cancel"))
+            .is_some_and(|task_id| {
+                !task_id.is_empty() && !task_id.contains('/') && !task_id.contains(':')
+            })
+}
+
 pub(crate) fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
     if left.len() != right.len() {
         return false;

diff --git a/packages/control-plane-rs/src/http.rs b/packages/control-plane-rs/src/http.rs
--- a/packages/control-plane-rs/src/http.rs
+++ b/packages/control-plane-rs/src/http.rs
@@ -11,7 +11,7 @@
 
 const MAX_HEADER_BYTES: usize = 64 * 1024;
 pub(crate) const MAX_JSON_BODY_BYTES: usize = 32 * 1024 * 1024;
-const CORS_ALLOW_HEADERS: &str = "authorization,content-type,x-composer-artifact-access,x-composer-api-key,x-composer-approval-mode,x-composer-client,x-composer-client-tools,x-composer-csrf,x-composer-agent-id,x-composer-slim-events,x-composer-workspace,x-composer-workspace-id,x-maestro-artifact-access,x-maestro-api-key,x-maestro-approval-mode,x-maestro-agent-id,x-maestro-client,x-maestro-client-tools,x-maestro-csrf,x-maestro-slim-events,x-maestro-workspace,x-maestro-workspace-id,x-csrf-token,x-xsrf-token";
+const CORS_ALLOW_HEADERS: &str = "authorization,content-type,a2a-version,a2a-extensions,traceparent,tracestate,x-organization-id,x-evalops-agent-id,x-evalops-actor-id,x-evalops-session-id,x-evalops-workspace-id,x-composer-artifact-access,x-composer-api-key,x-composer-approval-mode,x-composer-client,x-composer-client-tools,x-composer-csrf,x-composer-agent-id,x-composer-slim-events,x-composer-workspace,x-composer-workspace-id,x-maestro-artifact-access,x-maestro-api-key,x-maestro-approval-mode,x-maestro-agent-id,x-maestro-client,x-maestro-client-tools,x-maestro-csrf,x-maestro-slim-events,x-maestro-workspace,x-maestro-workspace-id,x-csrf-token,x-xsrf-token";
 
 #[derive(Debug)]
 pub(crate) struct RequestHead {

diff --git a/packages/control-plane-rs/src/main.rs b/packages/control-plane-rs/src/main.rs
--- a/packages/control-plane-rs/src/main.rs
+++ b/packages/control-plane-rs/src/main.rs
@@ -17,7 +17,7 @@
 use tokio::io::{AsyncReadExt, AsyncWriteExt};
 use tokio::net::{TcpListener, TcpStream};
 use tokio::process::Command;
-use tokio::sync::{mpsc, Mutex};
+use tokio::sync::{mpsc, watch, Mutex};
 
 mod auth;
 mod http;
@@ -49,9 +49,15 @@
 const DEFAULT_EXTRACT_MAX_CHARS: usize = 200_000;
 const MAX_EXTRACT_INPUT_BYTES: usize = 50 * 1024 * 1024;
 const MAX_PROJECT_ONBOARDING_IMPRESSIONS: u8 = 4;
+const A2A_PROTOCOL_VERSION: &str = "1.0";
+const A2A_DEFAULT_TURN_TIMEOUT_MS: u64 = 180_000;
+const A2A_DEFAULT_RESPONSE_END_SETTLE_MS: u64 = 250;
+const A2A_TERMINAL_TASK_STORE_LIMIT: usize = 128;
 static ATTACHMENT_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
 static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
 type PendingToolResponseSender = mpsc::UnboundedSender<(String, bool, Option<ToolResult>)>;
+type A2ACancelSender = watch::Sender<bool>;
+type A2ACancelReceiver = watch::Receiver<bool>;
 #[derive(Debug, Clone)]
 struct Config {
     listen_host: String,
@@ -151,6 +157,8 @@
     shared_sessions: Arc<Mutex<HashMap<String, SharedSessionGrant>>>,
     approval_modes: Arc<Mutex<HashMap<String, String>>>,
     pending_tool_responses: Arc<Mutex<HashMap<String, PendingToolResponseSender>>>,
+    a2a_tasks: Arc<Mutex<HashMap<String, Value>>>,
+    a2a_cancel_senders: Arc<Mutex<HashMap<String, A2ACancelSender>>>,
 }
 
 #[derive(Clone, Debug, Default, Serialize)]
@@ -295,6 +303,8 @@
         shared_sessions: Arc::new(Mutex::new(shared_sessions)),
         approval_modes: Arc::new(Mutex::new(HashMap::new())),
         pending_tool_responses: Arc::new(Mutex::new(HashMap::new())),
+        a2a_tasks: Arc::new(Mutex::new(HashMap::new())),
+        a2a_cancel_senders: Arc::new(Mutex::new(HashMap::new())),
     };
 
     loop {
@@ -329,6 +339,16 @@
             return handle_chat_endpoint(stream, initial, head, state).await;
         }
 
+        if is_a2a_endpoint(&head) {
+            let response = handle_a2a_endpoint(&mut stream, &mut initial, head, &state).await;
+            stream
+                .write_all(&response)
+                .await
+                .map_err(|error| error.to_string())?;
+            let _ = stream.shutdown().await;
+            return Ok(());
+        }
+
         if is_local_endpoint(&head) {
             let response = handle_local_endpoint(&mut stream, &mut initial, head, &state).await;
             stream
@@ -508,6 +528,1078 @@
     Some(request_id)
 }
 
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct A2APartBody {
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    text: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    url: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    data: Option<Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    metadata: Option<Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    filename: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    media_type: Option<String>,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct A2AMessageBody {
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    message_id: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    context_id: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    task_id: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    role: Option<String>,
+    #[serde(default)]
+    parts: Vec<A2APartBody>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    metadata: Option<Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    extensions: Option<Vec<String>>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    reference_task_ids: Option<Vec<String>>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct A2ASendMessageRequest {
+    message: A2AMessageBody,
+    #[serde(default)]
+    configuration: Option<Value>,
+    #[serde(default)]
+    metadata: Option<Value>,
+}
+
+#[derive(Debug, Default)]
+struct A2ATurnOutput {
+    assistant_text: String,
+    thinking_text: String,
+    usage: Option<TokenUsage>,
+    tools: Vec<Value>,
+}
+
+enum A2ATurnResult {
+    Completed(A2ATurnOutput),
+    Canceled,
+}
+
+#[derive(Debug)]
+struct A2ASendTarget {
+    task_id: String,
+    context_id: String,
+    history: Vec<Value>,
+    previous_task: Option<Value>,
+    metadata: Value,
+}
+
+fn is_a2a_endpoint(head: &RequestHead) -> bool {
+    if head.method == "OPTIONS" {
+        return head.path == "/.well-known/agent-card.json"
+            || head.path == "/message:send"
+            || head.path == "/tasks"
+            || head.path.starts_with("/tasks/");
+    }
+    matches!(
+        (head.method.as_str(), head.path.as_str()),
+        ("GET", "/.well-known/agent-card.json") | ("POST", "/message:send") | ("GET", "/tasks")
+    ) || (head.method == "GET" && a2a_task_id_from_get_path(&head.path).is_some())
+        || (head.method == "POST" && a2a_task_id_from_cancel_path(&head.path).is_some())
+}
+
+fn a2a_task_id_from_get_path(path: &str) -> Option<&str> {
+    let id = path.strip_prefix("/tasks/")?;
+    (!id.is_empty() && !id.contains('/') && !id.contains(':')).then_some(id)
+}
+
+fn a2a_task_id_from_cancel_path(path: &str) -> Option<&str> {
+    let id = path.strip_prefix("/tasks/")?.strip_suffix(":cancel")?;
+    (!id.is_empty() && !id.contains('/') && !id.contains(':')).then_some(id)
+}
+
+async fn handle_a2a_endpoint(
+    stream: &mut TcpStream,
+    initial: &mut Vec<u8>,
+    head: RequestHead,
+    state: &AppState,
+) -> Vec<u8> {
+    if head.method == "OPTIONS" {
+        return response(204, "text/plain; charset=utf-8", &[]);
+    }
+
+    if let Err(response) = validate_csrf(&head, &state.config) {
+        return response;
+    }
+
+    if head.method == "GET" && head.path == "/.well-known/agent-card.json" {
+        return json_response(200, &a2a_agent_card(&head, &state.config));
+    }
+
+    let Some(auth) = auth_context(&head, &state.config) else {
+        return json_response(401, &serde_json::json!({ "error": "Unauthorized" }));
+    };
+
+    if head.method == "GET" && head.path == "/tasks" {
+        let tasks = state
+            .a2a_tasks
+            .lock()
+            .await
+            .values()
+            .filter(|task| a2a_task_visible_to_auth(task, &auth))
+            .cloned()
+            .collect::<Vec<_>>();
+        return json_response(200, &serde_json::json!({ "tasks": tasks }));
+    }
+
+    if head.method == "GET" {
+        if let Some(task_id) = a2a_task_id_from_get_path(&head.path) {
+            let tasks = state.a2a_tasks.lock().await;
+            return tasks.get(task_id).map_or_else(
+                || a2a_error_response(404, "TASK_NOT_FOUND", "A2A task not found"),
+                |task| {
+                    if a2a_task_visible_to_auth(task, &auth) {
+                        json_response(200, task)
+                    } else {
+                        a2a_error_response(404, "TASK_NOT_FOUND", "A2A task not found")
+                    }
+                },
+            );
+        }
+    }
+
+    if head.method == "POST" {
+        if let Some(task_id) = a2a_task_id_from_cancel_path(&head.path) {
+            return match cancel_a2a_task(state, task_id, &auth).await {
+                Ok(task) => json_response(200, &task),
+                Err(response) => response,
+            };
+        }
+    }
+
+    if head.method == "POST" && head.path == "/message:send" {
+        return handle_a2a_message_send(stream, initial, &head, state, &auth).await;
+    }
+
+    a2a_error_response(404, "NOT_FOUND", "A2A endpoint not found")
+}
+
+async fn cancel_a2a_task(
+    state: &AppState,
+    task_id: &str,
+    auth: &AuthContext,
+) -> Result<Value, Vec<u8>> {
+    let mut tasks = state.a2a_tasks.lock().await;
+    let Some(task) = tasks.get_mut(task_id) else {
+        return Err(a2a_error_response(
+            404,
+            "TASK_NOT_FOUND",
+            "A2A task not found",
+        ));
+    };
+    if !a2a_task_visible_to_auth(task, auth) {
+        return Err(a2a_error_response(
+            404,
+            "TASK_NOT_FOUND",
+            "A2A task not found",
+        ));
+    }
+    if a2a_task_is_terminal(task) {
+        return Err(a2a_error_response(
+            400,
+            "TASK_NOT_CANCELABLE",
+            "A2A task cannot be canceled from its current state",
+        ));
+    }
+    let context_id = task
+        .get("contextId")
+        .and_then(Value::as_str)
+        .unwrap_or("a2a")
+        .to_string();
+    task["status"] = serde_json::json!({
+        "state": "TASK_STATE_CANCELED",
+        "message": a2a_agent_message(&context_id, "Task canceled"),
+        "timestamp": now_rfc3339()
+    });
+    task["artifacts"] = serde_json::json!([]);
+    let task = task.clone();
+    prune_a2a_terminal_tasks(&mut tasks);
+    drop(tasks);
+
+    if let Some(sender) = state.a2a_cancel_senders.lock().await.remove(task_id) {
+        let _ = sender.send(true);
+    }
+
+    Ok(task)
+}
+
+fn a2a_task_status_state(task: &Value) -> Option<&str> {
+    task.get("status")
+        .and_then(|status| status.get("state"))
+        .and_then(Value::as_str)
+}
+
+fn a2a_task_status_timestamp(task: &Value) -> Option<&str> {
+    task.get("status")
+        .and_then(|status| status.get("timestamp"))
+        .and_then(Value::as_str)
+}
+
+fn a2a_task_is_terminal(task: &Value) -> bool {
+    matches!(
+        a2a_task_status_state(task),
+        Some(
+            "TASK_STATE_COMPLETED"
+                | "TASK_STATE_FAILED"
+                | "TASK_STATE_CANCELED"
+                | "TASK_STATE_REJECTED"
+        )
+    )
+}
+
+fn a2a_task_accepts_message(task: &Value) -> bool {
+    a2a_task_status_state(task) == Some("TASK_STATE_INPUT_REQUIRED")
+}
+
+fn a2a_task_owner_subject(task: &Value) -> Option<&str> {
+    task.get("metadata")
+        .and_then(|metadata| metadata.get("ownerSubject"))
+        .and_then(Value::as_str)
+}
+
+fn a2a_task_visible_to_auth(task: &Value, auth: &AuthContext) -> bool {
+    if auth.unrestricted {
+        return true;
+    }
+    auth.subject
+        .as_deref()
+        .is_some_and(|subject| a2a_task_owner_subject(task) == Some(subject))
+}
+
+async fn claim_a2a_send_task(
+    state: &AppState,
+    request: &A2ASendMessageRequest,
+    head: &RequestHead,
+    auth: &AuthContext,
+    metadata: Value,
+) -> Result<A2ASendTarget, Vec<u8>> {
+    let requested_task_id = request
+        .message
+        .task_id
+        .as_deref()
+        .map(str::trim)
+        .filter(|task_id| !task_id.is_empty());
+    let explicit_context_id = request
+        .message
+        .context_id
+        .as_deref()
+        .map(str::trim)
+        .filter(|context_id| !context_id.is_empty())
+        .map(str::to_string);
+
+    let mut tasks = state.a2a_tasks.lock().await;
+    let (task_id, context_id, mut history, previous_task, task_metadata) =
+        if let Some(task_id) = requested_task_id {
+            let Some(task) = tasks.get(task_id) else {
+                return Err(a2a_error_response(
+                    404,
+                    "TASK_NOT_FOUND",
+                    "A2A task not found",
+                ));
+            };
+            if !a2a_task_visible_to_auth(task, auth) {
+                return Err(a2a_error_response(
+                    404,
+                    "TASK_NOT_FOUND",
+                    "A2A task not found",
+                ));
+            }
+            if a2a_task_is_terminal(task) {
+                return Err(a2a_error_response(
+                    400,
+                    "UNSUPPORTED_OPERATION",
+                    "A2A terminal tasks cannot accept more messages",
+                ));
+            }
+            if !a2a_task_accepts_message(task) {
+                return Err(a2a_error_response(
+                    409,
+                    "UNSUPPORTED_OPERATION",
+                    "A2A task is not ready to accept another message",
+                ));
+            }
+
+            let task_context_id = task
+                .get("contextId")
+                .and_then(Value::as_str)
+                .map(str::trim)
+                .filter(|context_id| !context_id.is_empty())
+                .map(str::to_string);
+            if let (Some(message_context_id), Some(task_context_id)) =
+                (explicit_context_id.as_deref(), task_context_id.as_deref())
+            {
+                if message_context_id != task_context_id {
+                    return Err(a2a_error_response(
+                        400,
+                        "INVALID_REQUEST",
+                        "A2A message contextId must match the referenced task",
+                    ));
+                }
+            }
+            let context_id = explicit_context_id
+                .or(task_context_id)
+                .unwrap_or_else(|| a2a_context_id(request, head));
+            let history = task
+                .get("history")
+                .and_then(Value::as_array)
+                .cloned()
+                .unwrap_or_default();
+            (
+                task_id.to_string(),
+                context_id,
+                history,
+                Some(task.clone()),
+                a2a_merge_task_metadata(task, metadata),
+            )
+        } else {
+            (
+                generate_a2a_id("maestro-task"),
+                explicit_context_id.unwrap_or_else(|| a2a_context_id(request, head)),
+                Vec::new(),
+                None,
+                metadata,
+            )
+        };
+    history.push(a2a_user_message_value(&request.message, &context_id));
+    let working_message = a2a_agent_message(&context_id, "Maestro is working on the A2A task.");
+    let task = a2a_task_value(
+        &task_id,
+        &context_id,
+        "TASK_STATE_WORKING",
+        working_message,
+        history.clone(),
+        Vec::new(),
+        task_metadata.clone(),
+    );
+    tasks.insert(task_id.clone(), task);
+    prune_a2a_terminal_tasks(&mut tasks);
+    Ok(A2ASendTarget {
+        task_id,
+        context_id,
+        history,
+        previous_task,
+        metadata: task_metadata,
+    })
+}
+
+fn a2a_merge_task_metadata(existing_task: &Value, metadata: Value) -> Value {
+    let mut merged = existing_task
+        .get("metadata")
+        .and_then(Value::as_object)
+        .cloned()
+        .unwrap_or_default();
+    if let Value::Object(metadata) = metadata {
+        for (key, value) in metadata {
+            merged.insert(key, value);
+        }
+    }
+    Value::Object(merged)
+}
+
+async fn rollback_a2a_send_claim(state: &AppState, task_id: &str, previous_task: Option<Value>) {
+    let mut tasks = state.a2a_tasks.lock().await;
+    if let Some(previous_task) = previous_task {
+        tasks.insert(task_id.to_string(), previous_task);
+    } else {
+        tasks.remove(task_id);
+    }
+}
+
+async fn a2a_canceled_task(state: &AppState, task_id: &str) -> Option<Value> {
+    state.a2a_tasks.lock().await.get(task_id).and_then(|task| {
+        (a2a_task_status_state(task) == Some("TASK_STATE_CANCELED")).then(|| task.clone())
+    })
+}
+
+async fn store_a2a_task_unless_canceled(state: &AppState, task_id: &str, task: Value) -> Value {
+    let mut tasks = state.a2a_tasks.lock().await;
+    if let Some(existing) = tasks.get(task_id) {
+        if a2a_task_status_state(existing) == Some("TASK_STATE_CANCELED") {
+            return existing.clone();
+        }
+    }
+    tasks.insert(task_id.to_string(), task.clone());
+    prune_a2a_terminal_tasks(&mut tasks);
+    task
+}
+
+fn prune_a2a_terminal_tasks(tasks: &mut HashMap<String, Value>) {
+    let mut terminal_tasks = tasks
+        .iter()
+        .filter(|(_, task)| a2a_task_is_terminal(task))
+        .map(|(task_id, task)| {
+            (
+                task_id.clone(),
+                a2a_task_status_timestamp(task)
+                    .unwrap_or_default()
+                    .to_string(),
+            )
+        })
+        .collect::<Vec<_>>();
+    if terminal_tasks.len() <= A2A_TERMINAL_TASK_STORE_LIMIT {
+        return;
+    }
+    terminal_tasks.sort_by(|(left_id, left_timestamp), (right_id, right_timestamp)| {
+        left_timestamp
+            .cmp(right_timestamp)
+            .then_with(|| left_id.cmp(right_id))
+    });
+    let overflow = terminal_tasks.len() - A2A_TERMINAL_TASK_STORE_LIMIT;
+    for (task_id, _) in terminal_tasks.into_iter().take(overflow) {
+        tasks.remove(&task_id);
+    }
+}
+
+async fn register_a2a_cancel_sender(
+    state: &AppState,
+    task_id: &str,
+    cancel_tx: A2ACancelSender,
+) -> Result<(), Vec<u8>> {
+    let mut senders = state.a2a_cancel_senders.lock().await;
+    if senders.contains_key(task_id) {
+        return Err(a2a_error_response(
+            409,
+            "UNSUPPORTED_OPERATION",
+            "A2A task is already running",
+        ));
+    }
+    senders.insert(task_id.to_string(), cancel_tx);
+    Ok(())
+}
+
+async fn handle_a2a_message_send(
+    stream: &mut TcpStream,
+    initial: &mut Vec<u8>,
+    head: &RequestHead,
+    state: &AppState,
+    auth: &AuthContext,
+) -> Vec<u8> {
+    let body = match read_request_body(stream, initial, head).await {
+        Ok(body) => body,
+        Err(error) => return a2a_error_response(400, "INVALID_REQUEST", &error),
+    };
+    let request: A2ASendMessageRequest = match serde_json::from_slice(&body) {
+        Ok(request) => request,
+        Err(error) => {
+            return a2a_error_response(
+                400,
+                "INVALID_REQUEST",
+                &format!("invalid A2A message request: {error}"),
+            );
+        }
+    };
+
+    let Some(prompt) = a2a_message_text(&request.message) else {
+        return a2a_error_response(
+            400,
+            "INVALID_REQUEST",
+            "A2A message must contain at least one text part",
+        );
+    };
+    let return_immediately = match a2a_return_immediately(&request) {
+        Ok(value) => value,
+        Err(error) => return a2a_error_response(400, "INVALID_REQUEST", error),
+    };
+
+    let metadata = a2a_task_metadata(head, &request, auth);
+    let target = match claim_a2a_send_task(state, &request, head, auth, metadata).await {
+        Ok(target) => target,
+        Err(response) => return response,
+    };
+    let task_id = target.task_id;
+    let context_id = target.context_id;
+    let history = target.history;
+    let previous_task = target.previous_task;
+    let metadata = target.metadata;
+
+    let (cancel_tx, cancel_rx) = watch::channel(false);
+    if let Err(response) = register_a2a_cancel_sender(state, &task_id, cancel_tx).await {
+        rollback_a2a_send_claim(state, &task_id, previous_task).await;
+        return response;
+    }
+    if let Some(task) = a2a_canceled_task(state, &task_id).await {
+        state.a2a_cancel_senders.lock().await.remove(&task_id);
+        return json_response(200, &serde_json::json!({ "task": task }));
+    }
+    if return_immediately {
+        let accepted_message = a2a_agent_message(&context_id, "Maestro accepted the A2A task.");
+        let mut accepted_history = history.clone();
+        accepted_history.push(accepted_message.clone());
+        let task = a2a_task_value(
+            &task_id,
+            &context_id,
+            "TASK_STATE_WORKING",
+            accepted_message.clone(),
+            accepted_history.clone(),
+            Vec::new(),
+            metadata.clone(),
+        );
+        let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+        let state = state.clone();
+        tokio::spawn(async move {
+            let _ = complete_a2a_task(
+                &state,
+                prompt,
+                task_id,
+                context_id,
+                accepted_history,
+                metadata,
+                cancel_rx,
+            )
+            .await;
+        });
+        return json_response(200, &serde_json::json!({ "task": task }));
+    }
+
+    let task = complete_a2a_task(
+        state, prompt, task_id, context_id, history, metadata, cancel_rx,
+    )
+    .await;
+    json_response(200, &serde_json::json!({ "task": task }))
+}
+
+async fn complete_a2a_task(
+    state: &AppState,
+    prompt: String,
+    task_id: String,
+    context_id: String,
+    mut history: Vec<Value>,
+    mut metadata: Value,
+    cancel_rx: A2ACancelReceiver,
+) -> Value {
+    let turn = match run_a2a_native_turn(state, prompt, cancel_rx).await {
+        Ok(A2ATurnResult::Completed(turn)) => turn,
+        Ok(A2ATurnResult::Canceled) => {
+            let message = a2a_agent_message(&context_id, "Task canceled");
+            history.push(message.clone());
+            let task = a2a_task_value(
+                &task_id,
+                &context_id,
+                "TASK_STATE_CANCELED",
+                message,
+                history,
+                Vec::new(),
+                metadata,
+            );
+            let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+            state.a2a_cancel_senders.lock().await.remove(&task_id);
+            return task;
+        }
+        Err(error) => {
+            let message = a2a_agent_message(&context_id, &error);
+            history.push(message.clone());
+            let task = a2a_task_value(
+                &task_id,
+                &context_id,
+                "TASK_STATE_FAILED",
+                message.clone(),
+                history,
+                Vec::new(),
+                metadata,
+            );
+            let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+            state.a2a_cancel_senders.lock().await.remove(&task_id);
+            return task;
+        }
+    };
+
+    let assistant_text = if turn.assistant_text.trim().is_empty() {
+        "Maestro completed the A2A task without a text response.".to_string()
+    } else {
+        turn.assistant_text
+    };
+    let agent_message = a2a_agent_message(&context_id, &assistant_text);
+    if !turn.thinking_text.trim().is_empty() {
+        metadata["thinking"] = Value::String(turn.thinking_text);
+    }
+    if !turn.tools.is_empty() {
+        metadata["tools"] = Value::Array(turn.tools);
+    }
+    if let Some(usage) = turn.usage {
+        metadata["usage"] = serde_json::json!({
+            "input": usage.input_tokens,
+            "output": usage.output_tokens,
+            "cacheRead": usage.cache_read_tokens,
+            "cacheWrite": usage.cache_write_tokens,
+            "cost": usage.cost.unwrap_or(0.0)
+        });
+    }
+    let task = a2a_task_value(
+        &task_id,
+        &context_id,
+        "TASK_STATE_COMPLETED",
+        agent_message.clone(),
+        {
+            history.push(agent_message);
+            history
+        },
+        vec![serde_json::json!({
+            "artifactId": format!("{task_id}-assistant-response"),
+            "name": "assistant-response",
+            "parts": [{ "text": assistant_text, "mediaType": "text/plain" }]
+        })],
+        metadata,
+    );
+    let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+    state.a2a_cancel_senders.lock().await.remove(&task_id);
+    task
+}
+
+fn a2a_agent_card(head: &RequestHead, config: &Config) -> Value {
+    let base_url = a2a_public_base_url(head, config);
+    serde_json::json!({
+        "protocolVersion": A2A_PROTOCOL_VERSION,
+        "name": trimmed_env("MAESTRO_A2A_AGENT_NAME")
+            .unwrap_or_else(|| "Maestro Desktop Agent".to_string()),
+        "description": "Local Maestro Rust/TS TUI agent endpoint for A2A task delegation.",
+        "url": base_url,
+        "preferredTransport": "HTTP+JSON",
+        "supportedInterfaces": [{
+            "url": base_url,
+            "protocolBinding": "HTTP+JSON",
+            "protocolVersion": A2A_PROTOCOL_VERSION
+        }],
+        "provider": {
+            "organization": "EvalOps",
+            "url": "https://evalops.com"
+        },
+        "version": env!("CARGO_PKG_VERSION"),
+        "capabilities": {
+            "streaming": false,
+            "pushNotifications": false,
+            "extendedAgentCard": false
+        },
+        "defaultInputModes": ["text/plain"],
+        "defaultOutputModes": ["text/plain", "application/json"],
+        "skills": [{
+            "id": "maestro-tui-turn",
+            "name": "Maestro TUI turn",
+            "description": "Run a prompt through the local Maestro native TUI agent runner.",
+            "tags": ["maestro", "tui", "codex", "a2a"],
+            "examples": [
+                "Review the current workspace and summarize the next highest leverage action."
+            ],
+            "inputModes": ["text/plain"],
+            "outputModes": ["text/plain", "application/json"]
+        }]
+    })
+}
+
+fn a2a_public_base_url(_head: &RequestHead, config: &Config) -> String {
+    if let Some(url) =
+        trimmed_env("MAESTRO_A2A_PUBLIC_URL").or_else(|| trimmed_env("MAESTRO_CONTROL_PUBLIC_URL"))
+    {
+        return url.trim_end_matches('/').to_string();
+    }
+    let host = if let Some(host) = trimmed_env("MAESTRO_A2A_PUBLIC_HOST")
... diff truncated: showing 800 of 3152 lines

You can send follow-ups to the cloud agent here.

Comment thread packages/control-plane-rs/src/main.rs
Comment thread packages/control-plane-rs/src/main.rs
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2f5a7d1f8b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread scripts/codex-a2a-bridge.py Outdated
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Duplicated cancel-path validation logic across modules
    • I extracted the A2A cancel-path parser into auth.rs and reused it from both the CSRF guard and router so the validation now lives in one place.
Preview (1e10692d6d)
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -104,6 +104,8 @@
     "smoke": "node scripts/smoke-cli.js",
     "smoke:local-e2e": "node scripts/smoke-cli.js",
     "smoke:event-bus": "tsx scripts/smoke-maestro-event-bus.ts",
+    "smoke:a2a-local": "tsx scripts/smoke-maestro-a2a-local.ts",
+    "a2a:codex-bridge": "python3 scripts/codex-a2a-bridge.py",
     "smoke:headless": "node scripts/smoke-headless.js",
     "headless:responsiveness": "node scripts/headless-responsiveness-harness.js",
     "smoke:pack": "node scripts/smoke-packed-cli.js",

diff --git a/packages/control-plane-rs/src/auth.rs b/packages/control-plane-rs/src/auth.rs
--- a/packages/control-plane-rs/src/auth.rs
+++ b/packages/control-plane-rs/src/auth.rs
@@ -185,9 +185,22 @@
 }
 
 pub(crate) fn csrf_applies(head: &RequestHead) -> bool {
-    head.path.starts_with("/api/") && !matches!(head.method.as_str(), "GET" | "HEAD" | "OPTIONS")
+    if matches!(head.method.as_str(), "GET" | "HEAD" | "OPTIONS") {
+        return false;
+    }
+
+    head.path.starts_with("/api/") || csrf_applies_to_a2a_path(&head.path)
 }
 
+pub(crate) fn a2a_task_id_from_cancel_path(path: &str) -> Option<&str> {
+    let task_id = path.strip_prefix("/tasks/")?.strip_suffix(":cancel")?;
+    (!task_id.is_empty() && !task_id.contains('/') && !task_id.contains(':')).then_some(task_id)
+}
+
+fn csrf_applies_to_a2a_path(path: &str) -> bool {
+    path == "/message:send" || a2a_task_id_from_cancel_path(path).is_some()
+}
+
 pub(crate) fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
     if left.len() != right.len() {
         return false;

diff --git a/packages/control-plane-rs/src/http.rs b/packages/control-plane-rs/src/http.rs
--- a/packages/control-plane-rs/src/http.rs
+++ b/packages/control-plane-rs/src/http.rs
@@ -11,7 +11,7 @@
 
 const MAX_HEADER_BYTES: usize = 64 * 1024;
 pub(crate) const MAX_JSON_BODY_BYTES: usize = 32 * 1024 * 1024;
-const CORS_ALLOW_HEADERS: &str = "authorization,content-type,x-composer-artifact-access,x-composer-api-key,x-composer-approval-mode,x-composer-client,x-composer-client-tools,x-composer-csrf,x-composer-agent-id,x-composer-slim-events,x-composer-workspace,x-composer-workspace-id,x-maestro-artifact-access,x-maestro-api-key,x-maestro-approval-mode,x-maestro-agent-id,x-maestro-client,x-maestro-client-tools,x-maestro-csrf,x-maestro-slim-events,x-maestro-workspace,x-maestro-workspace-id,x-csrf-token,x-xsrf-token";
+const CORS_ALLOW_HEADERS: &str = "authorization,content-type,a2a-version,a2a-extensions,traceparent,tracestate,x-organization-id,x-evalops-agent-id,x-evalops-actor-id,x-evalops-session-id,x-evalops-workspace-id,x-composer-artifact-access,x-composer-api-key,x-composer-approval-mode,x-composer-client,x-composer-client-tools,x-composer-csrf,x-composer-agent-id,x-composer-slim-events,x-composer-workspace,x-composer-workspace-id,x-maestro-artifact-access,x-maestro-api-key,x-maestro-approval-mode,x-maestro-agent-id,x-maestro-client,x-maestro-client-tools,x-maestro-csrf,x-maestro-slim-events,x-maestro-workspace,x-maestro-workspace-id,x-csrf-token,x-xsrf-token";
 
 #[derive(Debug)]
 pub(crate) struct RequestHead {

diff --git a/packages/control-plane-rs/src/main.rs b/packages/control-plane-rs/src/main.rs
--- a/packages/control-plane-rs/src/main.rs
+++ b/packages/control-plane-rs/src/main.rs
@@ -17,7 +17,7 @@
 use tokio::io::{AsyncReadExt, AsyncWriteExt};
 use tokio::net::{TcpListener, TcpStream};
 use tokio::process::Command;
-use tokio::sync::{mpsc, Mutex};
+use tokio::sync::{mpsc, watch, Mutex};
 
 mod auth;
 mod http;
@@ -49,9 +49,15 @@
 const DEFAULT_EXTRACT_MAX_CHARS: usize = 200_000;
 const MAX_EXTRACT_INPUT_BYTES: usize = 50 * 1024 * 1024;
 const MAX_PROJECT_ONBOARDING_IMPRESSIONS: u8 = 4;
+const A2A_PROTOCOL_VERSION: &str = "1.0";
+const A2A_DEFAULT_TURN_TIMEOUT_MS: u64 = 180_000;
+const A2A_DEFAULT_RESPONSE_END_SETTLE_MS: u64 = 250;
+const A2A_TERMINAL_TASK_STORE_LIMIT: usize = 128;
 static ATTACHMENT_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
 static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
 type PendingToolResponseSender = mpsc::UnboundedSender<(String, bool, Option<ToolResult>)>;
+type A2ACancelSender = watch::Sender<bool>;
+type A2ACancelReceiver = watch::Receiver<bool>;
 #[derive(Debug, Clone)]
 struct Config {
     listen_host: String,
@@ -151,6 +157,8 @@
     shared_sessions: Arc<Mutex<HashMap<String, SharedSessionGrant>>>,
     approval_modes: Arc<Mutex<HashMap<String, String>>>,
     pending_tool_responses: Arc<Mutex<HashMap<String, PendingToolResponseSender>>>,
+    a2a_tasks: Arc<Mutex<HashMap<String, Value>>>,
+    a2a_cancel_senders: Arc<Mutex<HashMap<String, A2ACancelSender>>>,
 }
 
 #[derive(Clone, Debug, Default, Serialize)]
@@ -295,6 +303,8 @@
         shared_sessions: Arc::new(Mutex::new(shared_sessions)),
         approval_modes: Arc::new(Mutex::new(HashMap::new())),
         pending_tool_responses: Arc::new(Mutex::new(HashMap::new())),
+        a2a_tasks: Arc::new(Mutex::new(HashMap::new())),
+        a2a_cancel_senders: Arc::new(Mutex::new(HashMap::new())),
     };
 
     loop {
@@ -329,6 +339,16 @@
             return handle_chat_endpoint(stream, initial, head, state).await;
         }
 
+        if is_a2a_endpoint(&head) {
+            let response = handle_a2a_endpoint(&mut stream, &mut initial, head, &state).await;
+            stream
+                .write_all(&response)
+                .await
+                .map_err(|error| error.to_string())?;
+            let _ = stream.shutdown().await;
+            return Ok(());
+        }
+
         if is_local_endpoint(&head) {
             let response = handle_local_endpoint(&mut stream, &mut initial, head, &state).await;
             stream
@@ -508,6 +528,1073 @@
     Some(request_id)
 }
 
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct A2APartBody {
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    text: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    url: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    data: Option<Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    metadata: Option<Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    filename: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    media_type: Option<String>,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct A2AMessageBody {
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    message_id: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    context_id: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    task_id: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    role: Option<String>,
+    #[serde(default)]
+    parts: Vec<A2APartBody>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    metadata: Option<Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    extensions: Option<Vec<String>>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    reference_task_ids: Option<Vec<String>>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct A2ASendMessageRequest {
+    message: A2AMessageBody,
+    #[serde(default)]
+    configuration: Option<Value>,
+    #[serde(default)]
+    metadata: Option<Value>,
+}
+
+#[derive(Debug, Default)]
+struct A2ATurnOutput {
+    assistant_text: String,
+    thinking_text: String,
+    usage: Option<TokenUsage>,
+    tools: Vec<Value>,
+}
+
+enum A2ATurnResult {
+    Completed(A2ATurnOutput),
+    Canceled,
+}
+
+#[derive(Debug)]
+struct A2ASendTarget {
+    task_id: String,
+    context_id: String,
+    history: Vec<Value>,
+    previous_task: Option<Value>,
+    metadata: Value,
+}
+
+fn is_a2a_endpoint(head: &RequestHead) -> bool {
+    if head.method == "OPTIONS" {
+        return head.path == "/.well-known/agent-card.json"
+            || head.path == "/message:send"
+            || head.path == "/tasks"
+            || head.path.starts_with("/tasks/");
+    }
+    matches!(
+        (head.method.as_str(), head.path.as_str()),
+        ("GET", "/.well-known/agent-card.json") | ("POST", "/message:send") | ("GET", "/tasks")
+    ) || (head.method == "GET" && a2a_task_id_from_get_path(&head.path).is_some())
+        || (head.method == "POST" && a2a_task_id_from_cancel_path(&head.path).is_some())
+}
+
+fn a2a_task_id_from_get_path(path: &str) -> Option<&str> {
+    let id = path.strip_prefix("/tasks/")?;
+    (!id.is_empty() && !id.contains('/') && !id.contains(':')).then_some(id)
+}
+
+async fn handle_a2a_endpoint(
+    stream: &mut TcpStream,
+    initial: &mut Vec<u8>,
+    head: RequestHead,
+    state: &AppState,
+) -> Vec<u8> {
+    if head.method == "OPTIONS" {
+        return response(204, "text/plain; charset=utf-8", &[]);
+    }
+
+    if let Err(response) = validate_csrf(&head, &state.config) {
+        return response;
+    }
+
+    if head.method == "GET" && head.path == "/.well-known/agent-card.json" {
+        return json_response(200, &a2a_agent_card(&head, &state.config));
+    }
+
+    let Some(auth) = auth_context(&head, &state.config) else {
+        return json_response(401, &serde_json::json!({ "error": "Unauthorized" }));
+    };
+
+    if head.method == "GET" && head.path == "/tasks" {
+        let tasks = state
+            .a2a_tasks
+            .lock()
+            .await
+            .values()
+            .filter(|task| a2a_task_visible_to_auth(task, &auth))
+            .cloned()
+            .collect::<Vec<_>>();
+        return json_response(200, &serde_json::json!({ "tasks": tasks }));
+    }
+
+    if head.method == "GET" {
+        if let Some(task_id) = a2a_task_id_from_get_path(&head.path) {
+            let tasks = state.a2a_tasks.lock().await;
+            return tasks.get(task_id).map_or_else(
+                || a2a_error_response(404, "TASK_NOT_FOUND", "A2A task not found"),
+                |task| {
+                    if a2a_task_visible_to_auth(task, &auth) {
+                        json_response(200, task)
+                    } else {
+                        a2a_error_response(404, "TASK_NOT_FOUND", "A2A task not found")
+                    }
+                },
+            );
+        }
+    }
+
+    if head.method == "POST" {
+        if let Some(task_id) = a2a_task_id_from_cancel_path(&head.path) {
+            return match cancel_a2a_task(state, task_id, &auth).await {
+                Ok(task) => json_response(200, &task),
+                Err(response) => response,
+            };
+        }
+    }
+
+    if head.method == "POST" && head.path == "/message:send" {
+        return handle_a2a_message_send(stream, initial, &head, state, &auth).await;
+    }
+
+    a2a_error_response(404, "NOT_FOUND", "A2A endpoint not found")
+}
+
+async fn cancel_a2a_task(
+    state: &AppState,
+    task_id: &str,
+    auth: &AuthContext,
+) -> Result<Value, Vec<u8>> {
+    let mut tasks = state.a2a_tasks.lock().await;
+    let Some(task) = tasks.get_mut(task_id) else {
+        return Err(a2a_error_response(
+            404,
+            "TASK_NOT_FOUND",
+            "A2A task not found",
+        ));
+    };
+    if !a2a_task_visible_to_auth(task, auth) {
+        return Err(a2a_error_response(
+            404,
+            "TASK_NOT_FOUND",
+            "A2A task not found",
+        ));
+    }
+    if a2a_task_is_terminal(task) {
+        return Err(a2a_error_response(
+            400,
+            "TASK_NOT_CANCELABLE",
+            "A2A task cannot be canceled from its current state",
+        ));
+    }
+    let context_id = task
+        .get("contextId")
+        .and_then(Value::as_str)
+        .unwrap_or("a2a")
+        .to_string();
+    task["status"] = serde_json::json!({
+        "state": "TASK_STATE_CANCELED",
+        "message": a2a_agent_message(&context_id, "Task canceled"),
+        "timestamp": now_rfc3339()
+    });
+    task["artifacts"] = serde_json::json!([]);
+    let task = task.clone();
+    prune_a2a_terminal_tasks(&mut tasks);
+    drop(tasks);
+
+    if let Some(sender) = state.a2a_cancel_senders.lock().await.remove(task_id) {
+        let _ = sender.send(true);
+    }
+
+    Ok(task)
+}
+
+fn a2a_task_status_state(task: &Value) -> Option<&str> {
+    task.get("status")
+        .and_then(|status| status.get("state"))
+        .and_then(Value::as_str)
+}
+
+fn a2a_task_status_timestamp(task: &Value) -> Option<&str> {
+    task.get("status")
+        .and_then(|status| status.get("timestamp"))
+        .and_then(Value::as_str)
+}
+
+fn a2a_task_is_terminal(task: &Value) -> bool {
+    matches!(
+        a2a_task_status_state(task),
+        Some(
+            "TASK_STATE_COMPLETED"
+                | "TASK_STATE_FAILED"
+                | "TASK_STATE_CANCELED"
+                | "TASK_STATE_REJECTED"
+        )
+    )
+}
+
+fn a2a_task_accepts_message(task: &Value) -> bool {
+    a2a_task_status_state(task) == Some("TASK_STATE_INPUT_REQUIRED")
+}
+
+fn a2a_task_owner_subject(task: &Value) -> Option<&str> {
+    task.get("metadata")
+        .and_then(|metadata| metadata.get("ownerSubject"))
+        .and_then(Value::as_str)
+}
+
+fn a2a_task_visible_to_auth(task: &Value, auth: &AuthContext) -> bool {
+    if auth.unrestricted {
+        return true;
+    }
+    auth.subject
+        .as_deref()
+        .is_some_and(|subject| a2a_task_owner_subject(task) == Some(subject))
+}
+
+async fn claim_a2a_send_task(
+    state: &AppState,
+    request: &A2ASendMessageRequest,
+    head: &RequestHead,
+    auth: &AuthContext,
+    metadata: Value,
+) -> Result<A2ASendTarget, Vec<u8>> {
+    let requested_task_id = request
+        .message
+        .task_id
+        .as_deref()
+        .map(str::trim)
+        .filter(|task_id| !task_id.is_empty());
+    let explicit_context_id = request
+        .message
+        .context_id
+        .as_deref()
+        .map(str::trim)
+        .filter(|context_id| !context_id.is_empty())
+        .map(str::to_string);
+
+    let mut tasks = state.a2a_tasks.lock().await;
+    let (task_id, context_id, mut history, previous_task, task_metadata) =
+        if let Some(task_id) = requested_task_id {
+            let Some(task) = tasks.get(task_id) else {
+                return Err(a2a_error_response(
+                    404,
+                    "TASK_NOT_FOUND",
+                    "A2A task not found",
+                ));
+            };
+            if !a2a_task_visible_to_auth(task, auth) {
+                return Err(a2a_error_response(
+                    404,
+                    "TASK_NOT_FOUND",
+                    "A2A task not found",
+                ));
+            }
+            if a2a_task_is_terminal(task) {
+                return Err(a2a_error_response(
+                    400,
+                    "UNSUPPORTED_OPERATION",
+                    "A2A terminal tasks cannot accept more messages",
+                ));
+            }
+            if !a2a_task_accepts_message(task) {
+                return Err(a2a_error_response(
+                    409,
+                    "UNSUPPORTED_OPERATION",
+                    "A2A task is not ready to accept another message",
+                ));
+            }
+
+            let task_context_id = task
+                .get("contextId")
+                .and_then(Value::as_str)
+                .map(str::trim)
+                .filter(|context_id| !context_id.is_empty())
+                .map(str::to_string);
+            if let (Some(message_context_id), Some(task_context_id)) =
+                (explicit_context_id.as_deref(), task_context_id.as_deref())
+            {
+                if message_context_id != task_context_id {
+                    return Err(a2a_error_response(
+                        400,
+                        "INVALID_REQUEST",
+                        "A2A message contextId must match the referenced task",
+                    ));
+                }
+            }
+            let context_id = explicit_context_id
+                .or(task_context_id)
+                .unwrap_or_else(|| a2a_context_id(request, head));
+            let history = task
+                .get("history")
+                .and_then(Value::as_array)
+                .cloned()
+                .unwrap_or_default();
+            (
+                task_id.to_string(),
+                context_id,
+                history,
+                Some(task.clone()),
+                a2a_merge_task_metadata(task, metadata),
+            )
+        } else {
+            (
+                generate_a2a_id("maestro-task"),
+                explicit_context_id.unwrap_or_else(|| a2a_context_id(request, head)),
+                Vec::new(),
+                None,
+                metadata,
+            )
+        };
+    history.push(a2a_user_message_value(&request.message, &context_id));
+    let working_message = a2a_agent_message(&context_id, "Maestro is working on the A2A task.");
+    let task = a2a_task_value(
+        &task_id,
+        &context_id,
+        "TASK_STATE_WORKING",
+        working_message,
+        history.clone(),
+        Vec::new(),
+        task_metadata.clone(),
+    );
+    tasks.insert(task_id.clone(), task);
+    prune_a2a_terminal_tasks(&mut tasks);
+    Ok(A2ASendTarget {
+        task_id,
+        context_id,
+        history,
+        previous_task,
+        metadata: task_metadata,
+    })
+}
+
+fn a2a_merge_task_metadata(existing_task: &Value, metadata: Value) -> Value {
+    let mut merged = existing_task
+        .get("metadata")
+        .and_then(Value::as_object)
+        .cloned()
+        .unwrap_or_default();
+    if let Value::Object(metadata) = metadata {
+        for (key, value) in metadata {
+            merged.insert(key, value);
+        }
+    }
+    Value::Object(merged)
+}
+
+async fn rollback_a2a_send_claim(state: &AppState, task_id: &str, previous_task: Option<Value>) {
+    let mut tasks = state.a2a_tasks.lock().await;
+    if let Some(previous_task) = previous_task {
+        tasks.insert(task_id.to_string(), previous_task);
+    } else {
+        tasks.remove(task_id);
+    }
+}
+
+async fn a2a_canceled_task(state: &AppState, task_id: &str) -> Option<Value> {
+    state.a2a_tasks.lock().await.get(task_id).and_then(|task| {
+        (a2a_task_status_state(task) == Some("TASK_STATE_CANCELED")).then(|| task.clone())
+    })
+}
+
+async fn store_a2a_task_unless_canceled(state: &AppState, task_id: &str, task: Value) -> Value {
+    let mut tasks = state.a2a_tasks.lock().await;
+    if let Some(existing) = tasks.get(task_id) {
+        if a2a_task_status_state(existing) == Some("TASK_STATE_CANCELED") {
+            return existing.clone();
+        }
+    }
+    tasks.insert(task_id.to_string(), task.clone());
+    prune_a2a_terminal_tasks(&mut tasks);
+    task
+}
+
+fn prune_a2a_terminal_tasks(tasks: &mut HashMap<String, Value>) {
+    let mut terminal_tasks = tasks
+        .iter()
+        .filter(|(_, task)| a2a_task_is_terminal(task))
+        .map(|(task_id, task)| {
+            (
+                task_id.clone(),
+                a2a_task_status_timestamp(task)
+                    .unwrap_or_default()
+                    .to_string(),
+            )
+        })
+        .collect::<Vec<_>>();
+    if terminal_tasks.len() <= A2A_TERMINAL_TASK_STORE_LIMIT {
+        return;
+    }
+    terminal_tasks.sort_by(|(left_id, left_timestamp), (right_id, right_timestamp)| {
+        left_timestamp
+            .cmp(right_timestamp)
+            .then_with(|| left_id.cmp(right_id))
+    });
+    let overflow = terminal_tasks.len() - A2A_TERMINAL_TASK_STORE_LIMIT;
+    for (task_id, _) in terminal_tasks.into_iter().take(overflow) {
+        tasks.remove(&task_id);
+    }
+}
+
+async fn register_a2a_cancel_sender(
+    state: &AppState,
+    task_id: &str,
+    cancel_tx: A2ACancelSender,
+) -> Result<(), Vec<u8>> {
+    let mut senders = state.a2a_cancel_senders.lock().await;
+    if senders.contains_key(task_id) {
+        return Err(a2a_error_response(
+            409,
+            "UNSUPPORTED_OPERATION",
+            "A2A task is already running",
+        ));
+    }
+    senders.insert(task_id.to_string(), cancel_tx);
+    Ok(())
+}
+
+async fn handle_a2a_message_send(
+    stream: &mut TcpStream,
+    initial: &mut Vec<u8>,
+    head: &RequestHead,
+    state: &AppState,
+    auth: &AuthContext,
+) -> Vec<u8> {
+    let body = match read_request_body(stream, initial, head).await {
+        Ok(body) => body,
+        Err(error) => return a2a_error_response(400, "INVALID_REQUEST", &error),
+    };
+    let request: A2ASendMessageRequest = match serde_json::from_slice(&body) {
+        Ok(request) => request,
+        Err(error) => {
+            return a2a_error_response(
+                400,
+                "INVALID_REQUEST",
+                &format!("invalid A2A message request: {error}"),
+            );
+        }
+    };
+
+    let Some(prompt) = a2a_message_text(&request.message) else {
+        return a2a_error_response(
+            400,
+            "INVALID_REQUEST",
+            "A2A message must contain at least one text part",
+        );
+    };
+    let return_immediately = match a2a_return_immediately(&request) {
+        Ok(value) => value,
+        Err(error) => return a2a_error_response(400, "INVALID_REQUEST", error),
+    };
+
+    let metadata = a2a_task_metadata(head, &request, auth);
+    let target = match claim_a2a_send_task(state, &request, head, auth, metadata).await {
+        Ok(target) => target,
+        Err(response) => return response,
+    };
+    let task_id = target.task_id;
+    let context_id = target.context_id;
+    let history = target.history;
+    let previous_task = target.previous_task;
+    let metadata = target.metadata;
+
+    let (cancel_tx, cancel_rx) = watch::channel(false);
+    if let Err(response) = register_a2a_cancel_sender(state, &task_id, cancel_tx).await {
+        rollback_a2a_send_claim(state, &task_id, previous_task).await;
+        return response;
+    }
+    if let Some(task) = a2a_canceled_task(state, &task_id).await {
+        state.a2a_cancel_senders.lock().await.remove(&task_id);
+        return json_response(200, &serde_json::json!({ "task": task }));
+    }
+    if return_immediately {
+        let accepted_message = a2a_agent_message(&context_id, "Maestro accepted the A2A task.");
+        let mut accepted_history = history.clone();
+        accepted_history.push(accepted_message.clone());
+        let task = a2a_task_value(
+            &task_id,
+            &context_id,
+            "TASK_STATE_WORKING",
+            accepted_message.clone(),
+            accepted_history.clone(),
+            Vec::new(),
+            metadata.clone(),
+        );
+        let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+        let state = state.clone();
+        tokio::spawn(async move {
+            let _ = complete_a2a_task(
+                &state,
+                prompt,
+                task_id,
+                context_id,
+                accepted_history,
+                metadata,
+                cancel_rx,
+            )
+            .await;
+        });
+        return json_response(200, &serde_json::json!({ "task": task }));
+    }
+
+    let task = complete_a2a_task(
+        state, prompt, task_id, context_id, history, metadata, cancel_rx,
+    )
+    .await;
+    json_response(200, &serde_json::json!({ "task": task }))
+}
+
+async fn complete_a2a_task(
+    state: &AppState,
+    prompt: String,
+    task_id: String,
+    context_id: String,
+    mut history: Vec<Value>,
+    mut metadata: Value,
+    cancel_rx: A2ACancelReceiver,
+) -> Value {
+    let turn = match run_a2a_native_turn(state, prompt, cancel_rx).await {
+        Ok(A2ATurnResult::Completed(turn)) => turn,
+        Ok(A2ATurnResult::Canceled) => {
+            let message = a2a_agent_message(&context_id, "Task canceled");
+            history.push(message.clone());
+            let task = a2a_task_value(
+                &task_id,
+                &context_id,
+                "TASK_STATE_CANCELED",
+                message,
+                history,
+                Vec::new(),
+                metadata,
+            );
+            let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+            state.a2a_cancel_senders.lock().await.remove(&task_id);
+            return task;
+        }
+        Err(error) => {
+            let message = a2a_agent_message(&context_id, &error);
+            history.push(message.clone());
+            let task = a2a_task_value(
+                &task_id,
+                &context_id,
+                "TASK_STATE_FAILED",
+                message.clone(),
+                history,
+                Vec::new(),
+                metadata,
+            );
+            let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+            state.a2a_cancel_senders.lock().await.remove(&task_id);
+            return task;
+        }
+    };
+
+    let assistant_text = if turn.assistant_text.trim().is_empty() {
+        "Maestro completed the A2A task without a text response.".to_string()
+    } else {
+        turn.assistant_text
+    };
+    let agent_message = a2a_agent_message(&context_id, &assistant_text);
+    if !turn.thinking_text.trim().is_empty() {
+        metadata["thinking"] = Value::String(turn.thinking_text);
+    }
+    if !turn.tools.is_empty() {
+        metadata["tools"] = Value::Array(turn.tools);
+    }
+    if let Some(usage) = turn.usage {
+        metadata["usage"] = serde_json::json!({
+            "input": usage.input_tokens,
+            "output": usage.output_tokens,
+            "cacheRead": usage.cache_read_tokens,
+            "cacheWrite": usage.cache_write_tokens,
+            "cost": usage.cost.unwrap_or(0.0)
+        });
+    }
+    let task = a2a_task_value(
+        &task_id,
+        &context_id,
+        "TASK_STATE_COMPLETED",
+        agent_message.clone(),
+        {
+            history.push(agent_message);
+            history
+        },
+        vec![serde_json::json!({
+            "artifactId": format!("{task_id}-assistant-response"),
+            "name": "assistant-response",
+            "parts": [{ "text": assistant_text, "mediaType": "text/plain" }]
+        })],
+        metadata,
+    );
+    let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+    state.a2a_cancel_senders.lock().await.remove(&task_id);
+    task
+}
+
+fn a2a_agent_card(head: &RequestHead, config: &Config) -> Value {
+    let base_url = a2a_public_base_url(head, config);
+    serde_json::json!({
+        "protocolVersion": A2A_PROTOCOL_VERSION,
+        "name": trimmed_env("MAESTRO_A2A_AGENT_NAME")
+            .unwrap_or_else(|| "Maestro Desktop Agent".to_string()),
+        "description": "Local Maestro Rust/TS TUI agent endpoint for A2A task delegation.",
+        "url": base_url,
+        "preferredTransport": "HTTP+JSON",
+        "supportedInterfaces": [{
+            "url": base_url,
+            "protocolBinding": "HTTP+JSON",
+            "protocolVersion": A2A_PROTOCOL_VERSION
+        }],
+        "provider": {
+            "organization": "EvalOps",
+            "url": "https://evalops.com"
+        },
+        "version": env!("CARGO_PKG_VERSION"),
+        "capabilities": {
+            "streaming": false,
+            "pushNotifications": false,
+            "extendedAgentCard": false
+        },
+        "defaultInputModes": ["text/plain"],
+        "defaultOutputModes": ["text/plain", "application/json"],
+        "skills": [{
+            "id": "maestro-tui-turn",
+            "name": "Maestro TUI turn",
+            "description": "Run a prompt through the local Maestro native TUI agent runner.",
+            "tags": ["maestro", "tui", "codex", "a2a"],
+            "examples": [
+                "Review the current workspace and summarize the next highest leverage action."
+            ],
+            "inputModes": ["text/plain"],
+            "outputModes": ["text/plain", "application/json"]
+        }]
+    })
+}
+
+fn a2a_public_base_url(_head: &RequestHead, config: &Config) -> String {
+    if let Some(url) =
+        trimmed_env("MAESTRO_A2A_PUBLIC_URL").or_else(|| trimmed_env("MAESTRO_CONTROL_PUBLIC_URL"))
+    {
+        return url.trim_end_matches('/').to_string();
+    }
+    let host = if let Some(host) = trimmed_env("MAESTRO_A2A_PUBLIC_HOST")
+        .or_else(|| trimmed_env("MAESTRO_CONTROL_PUBLIC_HOST"))
+    {
+        host
+    } else if config.listen_host == "0.0.0.0" || config.listen_host == "::" {
+        trimmed_env("HOSTNAME")
+            .or_else(|| trimmed_env("COMPUTERNAME"))
... diff truncated: showing 800 of 3193 lines

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 7ded0bc. Configure here.

Comment thread packages/control-plane-rs/src/auth.rs Outdated
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5c25f8f18f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread scripts/codex-a2a-bridge.py
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1e10692d6d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/control-plane-rs/src/main.rs Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants