Skip to main content

agent_engine/extensions/runtime/
process.rs

1//! Process-based extension runtime — JSON-RPC 2.0 over stdio.
2//!
3//! Spawns the extension as a child process. Communication uses
4//! Content-Length framing (LSP-style) over stdin/stdout.
5
6use std::collections::{HashMap, HashSet};
7use std::path::PathBuf;
8use std::process::Stdio;
9use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
10use std::sync::Arc;
11
12use async_trait::async_trait;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
16use tokio::process::{Child, ChildStdin, ChildStdout, Command};
17use tokio::sync::{mpsc, oneshot, Mutex, RwLock};
18use tokio::task::JoinHandle;
19
20use super::{ExtensionHandler, ExtensionHealth, RestartPolicy};
21use crate::extensions::hooks::events::{HookEvent, HookResult};
22use crate::extensions::manifest::CURRENT_EXTENSION_PROTOCOL_VERSION;
23
24#[derive(Serialize)]
25struct JsonRpcRequest {
26    jsonrpc: &'static str,
27    method: String,
28    params: Value,
29    id: u64,
30}
31
32#[derive(Serialize)]
33struct InitializeParams {
34    synaps_version: &'static str,
35    extension_protocol_version: u32,
36    plugin_id: String,
37    plugin_root: Option<String>,
38    config: Value,
39}
40
41#[derive(Debug, Clone, PartialEq, Deserialize)]
42pub struct RegisteredExtensionToolSpec {
43    pub name: String,
44    pub description: String,
45    pub input_schema: Value,
46}
47
48#[derive(Debug, Clone, PartialEq, Deserialize)]
49pub struct RegisteredProviderSpec {
50    pub id: String,
51    pub display_name: String,
52    pub description: String,
53    #[serde(default)]
54    pub models: Vec<RegisteredProviderModelSpec>,
55    #[serde(default)]
56    pub config_schema: Option<Value>,
57}
58
59#[derive(Debug, Clone, PartialEq, Deserialize)]
60pub struct RegisteredProviderModelSpec {
61    pub id: String,
62    #[serde(default)]
63    pub display_name: Option<String>,
64    #[serde(default)]
65    pub capabilities: Value,
66    #[serde(default)]
67    pub context_window: Option<u64>,
68}
69
70#[derive(Debug, Clone, Serialize)]
71pub struct ProviderCompleteParams {
72    pub provider_id: String,
73    pub model_id: String,
74    pub model: String,
75    pub messages: Vec<Value>,
76    pub system_prompt: Option<String>,
77    pub tools: Vec<Value>,
78    pub temperature: Option<f32>,
79    pub max_tokens: Option<u32>,
80    pub thinking_budget: u32,
81}
82
83#[derive(Debug, Clone, PartialEq, Deserialize)]
84pub struct ProviderCompleteResult {
85    pub content: Vec<Value>,
86    #[serde(default)]
87    pub stop_reason: Option<String>,
88    #[serde(default)]
89    pub usage: Option<Value>,
90}
91
92#[derive(Debug, Clone, PartialEq)]
93pub struct ProviderToolUse {
94    pub id: String,
95    pub name: String,
96    pub input: Value,
97}
98
99/// A single streaming event from a provider extension.
100#[derive(Debug, Clone, PartialEq)]
101pub enum ProviderStreamEvent {
102    /// Incremental assistant text.
103    TextDelta { text: String },
104    /// Incremental thinking text.
105    ThinkingDelta { text: String },
106    /// A complete tool-use block (matches ProviderToolUse fields).
107    ToolUse {
108        id: String,
109        name: String,
110        input: Value,
111    },
112    /// Usage metadata (typically near end-of-stream).
113    Usage { usage: Value },
114    /// Provider-side error (non-fatal stream notification — caller decides).
115    Error { message: String },
116    /// Optional explicit end-of-stream marker.
117    Done,
118}
119
120/// Parse a single `provider.stream.event` notification's `params` value into a
121/// [`ProviderStreamEvent`]. Returns `Err(String)` on malformed input.
122///
123/// Accepts both `{"event": {"type": "...", ...}}` and flat `{"type": "...", ...}`
124/// shapes.
125pub fn parse_provider_stream_event(params: &Value) -> Result<ProviderStreamEvent, String> {
126    let inner = match params.get("event") {
127        Some(ev) => ev,
128        None => params,
129    };
130    let obj = inner
131        .as_object()
132        .ok_or_else(|| "provider stream event must be a JSON object".to_string())?;
133
134    let ty = obj
135        .get("type")
136        .and_then(Value::as_str)
137        .ok_or_else(|| "provider stream event missing type".to_string())?;
138
139    match ty {
140        "text" => {
141            let text = obj
142                .get("delta")
143                .or_else(|| obj.get("text"))
144                .and_then(Value::as_str)
145                .ok_or_else(|| {
146                    "provider stream text event missing 'delta' or 'text'".to_string()
147                })?;
148            Ok(ProviderStreamEvent::TextDelta {
149                text: text.to_string(),
150            })
151        }
152        "thinking" => {
153            let text = obj
154                .get("delta")
155                .or_else(|| obj.get("text"))
156                .and_then(Value::as_str)
157                .ok_or_else(|| {
158                    "provider stream thinking event missing 'delta' or 'text'".to_string()
159                })?;
160            Ok(ProviderStreamEvent::ThinkingDelta {
161                text: text.to_string(),
162            })
163        }
164        "tool_use" => {
165            let id = obj
166                .get("id")
167                .and_then(Value::as_str)
168                .ok_or_else(|| "provider stream tool_use missing id".to_string())?;
169            if id.is_empty() {
170                return Err("provider stream tool_use id must be non-empty".to_string());
171            }
172            let name = obj
173                .get("name")
174                .and_then(Value::as_str)
175                .ok_or_else(|| "provider stream tool_use missing name".to_string())?;
176            if name.is_empty() {
177                return Err("provider stream tool_use name must be non-empty".to_string());
178            }
179            let input = match obj.get("input") {
180                None => Value::Object(Default::default()),
181                Some(v) if v.is_object() => v.clone(),
182                Some(_) => {
183                    return Err("provider stream tool_use input must be a JSON object".to_string());
184                }
185            };
186            Ok(ProviderStreamEvent::ToolUse {
187                id: id.to_string(),
188                name: name.to_string(),
189                input,
190            })
191        }
192        "usage" => {
193            let mut clone = obj.clone();
194            clone.remove("type");
195            Ok(ProviderStreamEvent::Usage {
196                usage: Value::Object(clone),
197            })
198        }
199        "error" => {
200            let message = obj
201                .get("message")
202                .and_then(Value::as_str)
203                .ok_or_else(|| "provider stream error missing message".to_string())?;
204            if message.is_empty() {
205                return Err("provider stream error message must be non-empty".to_string());
206            }
207            Ok(ProviderStreamEvent::Error {
208                message: message.to_string(),
209            })
210        }
211        "done" => Ok(ProviderStreamEvent::Done),
212        other => Err(format!("unknown provider stream event type: {other}")),
213    }
214}
215
216pub async fn execute_provider_tool_use(
217    registry: &crate::ToolRegistry,
218    hook_bus: &Arc<crate::extensions::hooks::HookBus>,
219    tool_use: ProviderToolUse,
220    ctx: crate::ToolContext,
221    max_tool_output: usize,
222) -> Value {
223    let tool_id = tool_use.id;
224    let tool_name = tool_use.name;
225    let input = tool_use.input;
226
227    let Some(tool) = registry.get(&tool_name).cloned() else {
228        return serde_json::json!({
229            "type": "tool_result",
230            "tool_use_id": tool_id,
231            "content": format!("Unknown tool: {}", tool_name),
232            "is_error": true,
233        });
234    };
235
236    let runtime_name = registry.runtime_name_for_api(&tool_name).to_string();
237    let input = registry.translate_input_for_api_tool(&tool_name, input);
238    let decision = crate::runtime::resolve_before_tool_call_decision(
239        input.clone(),
240        crate::runtime::emit_before_tool_call(
241            hook_bus,
242            &tool_name,
243            Some(&runtime_name),
244            input.clone(),
245        )
246        .await,
247        ctx.capabilities.secret_prompt.as_ref(),
248        false,
249    )
250    .await;
251
252    let crate::runtime::BeforeToolCallDecision::Continue { input } = decision else {
253        let crate::runtime::BeforeToolCallDecision::Block { reason } = decision else {
254            unreachable!()
255        };
256        return serde_json::json!({
257            "type": "tool_result",
258            "tool_use_id": tool_id,
259            "content": format!("Tool call blocked by extension: {}", reason),
260            "is_error": true,
261        });
262    };
263
264    let input_for_hook = input.clone();
265    let (result, is_error) = match tool.execute(input, ctx).await {
266        Ok(output) => (output, false),
267        Err(error) => (format!("Tool execution failed: {}", error), true),
268    };
269    let result = crate::runtime::emit_after_tool_call(
270        hook_bus,
271        &tool_name,
272        Some(&runtime_name),
273        input_for_hook,
274        result,
275        max_tool_output,
276    )
277    .await;
278
279    let mut response = serde_json::json!({
280        "type": "tool_result",
281        "tool_use_id": tool_id,
282        "content": crate::truncate_str(&result, max_tool_output).to_string(),
283    });
284    if is_error {
285        response["is_error"] = serde_json::json!(true);
286    }
287    response
288}
289
290pub async fn complete_provider_with_tools<F>(
291    handler: Arc<dyn ExtensionHandler>,
292    mut params: ProviderCompleteParams,
293    registry: &crate::ToolRegistry,
294    hook_bus: &Arc<crate::extensions::hooks::HookBus>,
295    mut context_factory: F,
296    max_tool_output: usize,
297    max_iterations: usize,
298) -> Result<ProviderCompleteResult, String>
299where
300    F: FnMut() -> crate::ToolContext,
301{
302    let max_iterations = max_iterations.max(1);
303    for iteration in 0..max_iterations {
304        let result = handler.provider_complete(params.clone()).await?;
305        let tool_uses = extract_provider_tool_uses(&result.content)?;
306        if tool_uses.is_empty() {
307            return Ok(result);
308        }
309        if iteration + 1 == max_iterations {
310            return Err(format!(
311                "extension provider '{}' exceeded provider tool-use iteration limit ({})",
312                handler.id(),
313                max_iterations,
314            ));
315        }
316
317        let assistant_content = result.content.clone();
318        params.messages.push(serde_json::json!({
319            "role": "assistant",
320            "content": assistant_content,
321        }));
322
323        let mut tool_results = Vec::with_capacity(tool_uses.len());
324        for tool_use in tool_uses {
325            tool_results.push(
326                execute_provider_tool_use(
327                    registry,
328                    hook_bus,
329                    tool_use,
330                    context_factory(),
331                    max_tool_output,
332                )
333                .await,
334            );
335        }
336        params.messages.push(serde_json::json!({
337            "role": "user",
338            "content": tool_results,
339        }));
340    }
341    Err(format!(
342        "extension provider '{}' exceeded provider tool-use iteration limit ({})",
343        handler.id(),
344        max_iterations,
345    ))
346}
347
348pub fn extract_provider_tool_uses(content: &[Value]) -> Result<Vec<ProviderToolUse>, String> {
349    let mut tool_uses = Vec::new();
350    for block in content {
351        if block.get("type").and_then(Value::as_str) != Some("tool_use") {
352            continue;
353        }
354        let id = block
355            .get("id")
356            .and_then(Value::as_str)
357            .ok_or_else(|| "provider tool_use missing id".to_string())?;
358        let name = block
359            .get("name")
360            .and_then(Value::as_str)
361            .ok_or_else(|| "provider tool_use missing name".to_string())?;
362        if id.trim().is_empty() {
363            return Err("provider tool_use id is empty".to_string());
364        }
365        if name.trim().is_empty() {
366            return Err("provider tool_use name is empty".to_string());
367        }
368        let input = block
369            .get("input")
370            .cloned()
371            .unwrap_or_else(|| serde_json::json!({}));
372        if !input.is_object() {
373            return Err(format!(
374                "provider tool_use '{}' input must be a JSON object",
375                id
376            ));
377        }
378        tool_uses.push(ProviderToolUse {
379            id: id.to_string(),
380            name: name.to_string(),
381            input,
382        });
383    }
384    Ok(tool_uses)
385}
386
387#[derive(Debug, Clone, Default, PartialEq)]
388pub struct InitializeCapabilitiesResult {
389    pub tools: Vec<RegisteredExtensionToolSpec>,
390    pub providers: Vec<RegisteredProviderSpec>,
391    /// Generic, plugin-defined capabilities. Each entry's `kind` is a
392    /// free-form string the plugin author picks (e.g. `"capture"`,
393    /// `"ocr"`, `"agent"`, `"foot_pedal"`); core does not enumerate or
394    /// branch on it. Permissions are gated by the declared
395    /// [`CapabilityDeclaration::permissions`] list.
396    pub capabilities: Vec<CapabilityDeclaration>,
397}
398
399/// Generic capability declaration returned by an extension's
400/// `initialize` response.
401///
402/// Core does not interpret `kind` or `params` — those are contracts
403/// between the plugin and whatever consumer (chatui glue, another
404/// plugin, an external watcher) cares about that capability kind. The
405/// host only enforces that the plugin holds every permission it
406/// declares it needs.
407#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
408pub struct CapabilityDeclaration {
409    /// Free-form capability kind tag chosen by the plugin author.
410    /// Examples: `"capture"`, `"ocr"`, `"agent"`, `"clipboard_mirror"`.
411    /// Must be non-empty; otherwise opaque to core.
412    pub kind: String,
413    /// Human-readable display name shown in `/extensions status` and
414    /// similar surfaces. Must be non-empty.
415    pub name: String,
416    /// Permission names this capability needs at runtime, e.g.
417    /// `["audio.input"]`. Every entry must parse to a known
418    /// [`crate::extensions::permissions::Permission`] *and* be present
419    /// in the extension's granted permission set.
420    #[serde(default)]
421    pub permissions: Vec<String>,
422    /// Free-form metadata. Core stores and forwards but never
423    /// interprets. Plugins use this for endpoint URLs, mode lists,
424    /// version tags, anything that doesn't fit the typed slots.
425    #[serde(default, skip_serializing_if = "is_null_value")]
426    pub params: serde_json::Value,
427}
428
429fn is_null_value(v: &serde_json::Value) -> bool {
430    v.is_null()
431}
432
433/// Validate a [`CapabilityDeclaration`] against the granted permission
434/// set.
435///
436/// Rules — generic, no enumeration of capability kinds:
437/// - `kind` must be non-empty.
438/// - `name` must be non-empty.
439/// - Every string in `permissions` must parse to a known
440///   [`crate::extensions::permissions::Permission`].
441/// - Every parsed permission must be present in `granted`.
442pub fn validate_capability(
443    decl: &CapabilityDeclaration,
444    granted: &crate::extensions::permissions::PermissionSet,
445) -> Result<(), String> {
446    use crate::extensions::permissions::Permission;
447    if decl.kind.trim().is_empty() {
448        return Err("capability 'kind' must be non-empty".to_string());
449    }
450    if decl.name.trim().is_empty() {
451        return Err("capability 'name' must be non-empty".to_string());
452    }
453    for perm_name in &decl.permissions {
454        let parsed = Permission::parse(perm_name).ok_or_else(|| {
455            format!(
456                "capability '{}' declares unknown permission '{}'",
457                decl.kind, perm_name
458            )
459        })?;
460        if !granted.has(parsed) {
461            return Err(format!(
462                "capability '{}' requires permission '{}' but it is not granted",
463                decl.kind, perm_name
464            ));
465        }
466    }
467    Ok(())
468}
469
470#[derive(Deserialize)]
471struct InitializeResult {
472    protocol_version: u32,
473    #[serde(default)]
474    capabilities: InitializeCapabilities,
475}
476
477#[derive(Default, Deserialize)]
478struct InitializeCapabilities {
479    #[serde(default)]
480    tools: Vec<RegisteredExtensionToolSpec>,
481    #[serde(default)]
482    providers: Vec<RegisteredProviderSpec>,
483    /// Canonical generic capability list.
484    #[serde(default)]
485    capabilities: Vec<CapabilityDeclaration>,
486}
487
488/// A JSON-RPC notification frame received from an extension (no `id`).
489///
490/// Internal API exposed publicly (`#[doc(hidden)]`) so integration tests
491/// can subscribe to notifications via [`ProcessExtension::subscribe_notifications`].
492/// Extension authors should not depend on this type — it may change without notice.
493#[doc(hidden)]
494#[derive(Debug, Clone)]
495pub struct NotificationFrame {
496    pub method: String,
497    pub params: Value,
498}
499
500/// Shared mailbox for the background reader task. Holds in-flight request
501/// senders (keyed by JSON-RPC id) and an optional notification subscriber.
502///
503/// Persists across process restarts: a new reader task replaces the old one
504/// but the `Inbox` itself is reused. Pending requests are drained with
505/// errors when the reader observes EOF or a transport failure.
506struct Inbox {
507    pending: Mutex<HashMap<u64, oneshot::Sender<Result<Value, String>>>>,
508    /// Fan-out notification subscribers. Each subscriber is keyed by a monotonic
509    /// id so it can unsubscribe itself without disturbing other concurrent
510    /// subscribers (e.g. background watchers that overlap with
511    /// `command.invoke` / `provider.stream`). Dead senders (receiver dropped)
512    /// are pruned lazily on dispatch and on `unsubscribe_notifications`.
513    notification_sinks: Mutex<Vec<(usize, mpsc::UnboundedSender<NotificationFrame>)>>,
514    /// Monotonic id allocator for `notification_sinks`. Wraparound is not a
515    /// concern in practice — `usize` is enormous relative to subscription
516    /// lifetimes — but uniqueness is only required among live subscribers.
517    next_sink_id: std::sync::atomic::AtomicUsize,
518    /// Set to true when the reader task exits (EOF or error). Used to prevent
519    /// callers from registering pending requests that will never be fulfilled.
520    closed: std::sync::atomic::AtomicBool,
521    /// Permissions granted to the calling extension. Set after manifest
522    /// validation; checked by inbound RPC handlers (e.g. memory.append).
523    permissions: RwLock<Option<crate::extensions::permissions::PermissionSet>>,
524    /// Stdin handle of the currently-running child process. Used by the
525    /// reader task to write JSON-RPC responses for inbound requests.
526    /// Replaced on each spawn (initial spawn + restarts).
527    inbound_stdin: Mutex<Option<Arc<Mutex<ChildStdin>>>>,
528    /// Extension id, used for namespace policy and diagnostics.
529    extension_id: String,
530}
531
532impl Inbox {
533    fn new(extension_id: String) -> Self {
534        Self {
535            pending: Mutex::new(HashMap::new()),
536            notification_sinks: Mutex::new(Vec::new()),
537            next_sink_id: std::sync::atomic::AtomicUsize::new(0),
538            closed: std::sync::atomic::AtomicBool::new(false),
539            permissions: RwLock::new(None),
540            inbound_stdin: Mutex::new(None),
541            extension_id,
542        }
543    }
544
545    /// Drains all pending request senders, sending `Err(reason)` to each.
546    /// Also marks the inbox as closed so no new requests can be registered.
547    async fn fail_all_pending(&self, reason: &str) {
548        self.closed
549            .store(true, std::sync::atomic::Ordering::Release);
550        let drained: Vec<_> = {
551            let mut pending = self.pending.lock().await;
552            pending.drain().collect()
553        };
554        for (_, tx) in drained {
555            let _ = tx.send(Err(reason.to_string()));
556        }
557    }
558}
559
560struct ProcessState {
561    child: Child,
562    stdin: Arc<Mutex<ChildStdin>>,
563    reader_handle: JoinHandle<()>,
564}
565
566/// A running extension process communicating via JSON-RPC 2.0 over stdio.
567pub struct ProcessExtension {
568    id: String,
569    command: String,
570    args: Vec<String>,
571    cwd: Option<PathBuf>,
572    state: Arc<Mutex<Option<ProcessState>>>,
573    /// Serializes a full request/response exchange and restart attempts.
574    call_lock: Arc<Mutex<()>>,
575    next_id: AtomicU64,
576    restart_count: AtomicUsize,
577    /// Lifetime restart total — never reset. `restart_count` tracks
578    /// *consecutive* failures (reset on successful restart, H-8) for the
579    /// exhaustion budget; this one feeds health/reporting so a previously
580    /// crashed extension still reads Degraded instead of pretending it
581    /// never crashed.
582    total_restarts: AtomicUsize,
583    /// Restart policy controlling exponential backoff and budget.
584    pub(crate) restart_policy: RestartPolicy,
585    /// Shared mailbox between the reader task and request callers. Persists
586    /// across process restarts so that any active notification subscriber
587    /// survives a restart-on-error.
588    inbox: Arc<Inbox>,
589}
590
591impl ProcessExtension {
592    pub async fn spawn(id: &str, command: &str, args: &[String]) -> Result<Self, String> {
593        Self::spawn_with_cwd(id, command, args, None).await
594    }
595
596    /// Spawn `command` with `args` and optional working directory.
597    ///
598    /// Child stderr is captured and forwarded to debug tracing with the extension
599    /// id so extension authors can inspect diagnostics without corrupting stdout.
600    pub async fn spawn_with_cwd(
601        id: &str,
602        command: &str,
603        args: &[String],
604        cwd: Option<PathBuf>,
605    ) -> Result<Self, String> {
606        let inbox = Arc::new(Inbox::new(id.to_string()));
607        let state = Self::spawn_state(id, command, args, cwd.as_ref(), inbox.clone()).await?;
608        Ok(Self {
609            id: id.to_string(),
610            command: command.to_string(),
611            args: args.to_vec(),
612            cwd,
613            state: Arc::new(Mutex::new(Some(state))),
614            call_lock: Arc::new(Mutex::new(())),
615            next_id: AtomicU64::new(1),
616            restart_count: AtomicUsize::new(0),
617            total_restarts: AtomicUsize::new(0),
618            restart_policy: RestartPolicy::default(),
619            inbox,
620        })
621    }
622
623    /// Override the restart policy. Intended for tests.
624    pub fn with_restart_policy(mut self, policy: RestartPolicy) -> Self {
625        self.restart_policy = policy;
626        self
627    }
628
629    async fn spawn_state(
630        id: &str,
631        command: &str,
632        args: &[String],
633        cwd: Option<&PathBuf>,
634        inbox: Arc<Inbox>,
635    ) -> Result<ProcessState, String> {
636        let mut cmd = Command::new(command);
637        cmd.args(args)
638            .stdin(Stdio::piped())
639            .stdout(Stdio::piped())
640            .stderr(Stdio::piped());
641        if let Some(cwd) = cwd {
642            cmd.current_dir(cwd);
643        }
644
645        // Clear the inherited environment so extensions cannot read secrets from
646        // the parent process (e.g. ANTHROPIC_API_KEY, SSH_AUTH_SOCK, AWS_*).
647        // Only forward a minimal set of safe, non-sensitive variables.
648        // TODO: forward the extension's declared `secret_env` values here once
649        //       secret_env is resolved at spawn time rather than config time.
650        cmd.env_clear();
651        for var in &["PATH", "HOME", "LANG", "TERM", "XDG_RUNTIME_DIR"] {
652            if let Ok(val) = std::env::var(var) {
653                cmd.env(var, val);
654            }
655        }
656
657        cmd.kill_on_drop(true);
658
659        let mut child = cmd
660            .spawn()
661            .map_err(|e| format!("Failed to spawn extension '{}': {}", id, e))?;
662
663        let stdin = child
664            .stdin
665            .take()
666            .ok_or_else(|| format!("No stdin for extension '{}'", id))?;
667        let stdout = child
668            .stdout
669            .take()
670            .ok_or_else(|| format!("No stdout for extension '{}'", id))?;
671        if let Some(stderr) = child.stderr.take() {
672            let extension_id = id.to_string();
673            tokio::spawn(async move {
674                let mut lines = BufReader::new(stderr).lines();
675                loop {
676                    match lines.next_line().await {
677                        Ok(Some(line)) => {
678                            tracing::debug!(extension = %extension_id, stderr = %line);
679                        }
680                        Ok(None) => break,
681                        Err(error) => {
682                            tracing::debug!(
683                                extension = %extension_id,
684                                error = %error,
685                                "Failed to read extension stderr",
686                            );
687                            break;
688                        }
689                    }
690                }
691            });
692        }
693
694        let reader_handle = Self::spawn_reader(stdout, inbox.clone(), id.to_string());
695
696        let stdin_arc = Arc::new(Mutex::new(stdin));
697        // Publish current stdin into the inbox so the reader task can write
698        // JSON-RPC responses for inbound requests (e.g. memory.append).
699        *inbox.inbound_stdin.lock().await = Some(stdin_arc.clone());
700
701        Ok(ProcessState {
702            child,
703            stdin: stdin_arc,
704            reader_handle,
705        })
706    }
707
708    /// Spawn the background reader task that owns `stdout`, demultiplexing
709    /// JSON-RPC responses (by id) and notifications (no id) into the shared
710    /// [`Inbox`]. Returns a `JoinHandle` so callers can `.abort()` it on
711    /// restart or shutdown.
712    fn spawn_reader(
713        stdout: ChildStdout,
714        inbox: Arc<Inbox>,
715        extension_id: String,
716    ) -> JoinHandle<()> {
717        tokio::spawn(async move {
718            let mut reader = BufReader::new(stdout);
719            loop {
720                match Self::read_one_frame(&mut reader, &extension_id).await {
721                    Ok(Some(value)) => {
722                        Self::dispatch_frame(value, &inbox, &extension_id).await;
723                    }
724                    Ok(None) => {
725                        tracing::debug!(
726                            extension = %extension_id,
727                            "Extension stdout closed (EOF); failing pending requests",
728                        );
729                        inbox.fail_all_pending("transport closed: EOF").await;
730                        // Drop all notification subscribers on EOF.
731                        inbox.notification_sinks.lock().await.clear();
732                        return;
733                    }
734                    Err(error) => {
735                        tracing::debug!(
736                            extension = %extension_id,
737                            error = %error,
738                            "Extension transport read error",
739                        );
740                        inbox
741                            .fail_all_pending(&format!("transport error: {}", error))
742                            .await;
743                        inbox.notification_sinks.lock().await.clear();
744                        return;
745                    }
746                }
747            }
748        })
749    }
750
751    /// Read one Content-Length-framed JSON message from `reader`. Returns
752    /// `Ok(None)` on a clean EOF *before* any header bytes are read; any
753    /// other unexpected EOF is reported as `Err`.
754    async fn read_one_frame(
755        reader: &mut BufReader<ChildStdout>,
756        extension_id: &str,
757    ) -> Result<Option<Value>, String> {
758        let mut content_length: Option<usize> = None;
759        let mut saw_any_header = false;
760        loop {
761            let mut header_line = String::new();
762            let n = reader
763                .read_line(&mut header_line)
764                .await
765                .map_err(|e| format!("Read header error: {}", e))?;
766            if n == 0 {
767                if saw_any_header {
768                    return Err("Unexpected EOF while reading response headers".into());
769                }
770                return Ok(None);
771            }
772            saw_any_header = true;
773            if header_line.len() > 1024 {
774                return Err(format!(
775                    "Extension '{}' header line too long ({} bytes)",
776                    extension_id,
777                    header_line.len()
778                ));
779            }
780            let trimmed = header_line.trim();
781            if trimmed.is_empty() {
782                break;
783            }
784            if let Some((name, value)) = trimmed.split_once(':') {
785                if name.trim().eq_ignore_ascii_case("Content-Length") {
786                    content_length = Some(value.trim().parse().map_err(|_| {
787                        format!("Invalid Content-Length value: {:?}", value.trim())
788                    })?);
789                }
790            }
791        }
792        let content_length = content_length.ok_or_else(|| {
793            format!(
794                "Extension '{}' frame missing Content-Length header",
795                extension_id
796            )
797        })?;
798        const MAX_RESPONSE_SIZE: usize = 4 * 1024 * 1024;
799        if content_length > MAX_RESPONSE_SIZE {
800            return Err(format!(
801                "Extension '{}' frame too large: {} bytes (max {})",
802                extension_id, content_length, MAX_RESPONSE_SIZE
803            ));
804        }
805        let mut buf = vec![0u8; content_length];
806        tokio::io::AsyncReadExt::read_exact(reader, &mut buf)
807            .await
808            .map_err(|e| format!("Read body error: {}", e))?;
809        let value: Value =
810            serde_json::from_slice(&buf).map_err(|e| format!("Parse frame error: {}", e))?;
811        Ok(Some(value))
812    }
813
814    /// Route a parsed JSON-RPC frame to the right consumer:
815    /// - response (`id` numeric, no `method`) → matching pending oneshot
816    /// - request (`id` numeric and `method`) → inbound request handler
817    /// - notification (`method` set, no `id`) → notification subscriber
818    /// - anything else → trace and drop
819    async fn dispatch_frame(value: Value, inbox: &Arc<Inbox>, extension_id: &str) {
820        let id_field = value.get("id");
821        let id_is_present = !matches!(id_field, None | Some(Value::Null));
822        let method_field = value
823            .get("method")
824            .and_then(Value::as_str)
825            .map(str::to_string);
826
827        if id_is_present && method_field.is_some() {
828            // Inbound request from the extension. Spawn a task to handle it
829            // so the reader loop is never blocked on memory I/O or other work.
830            let id = match id_field.and_then(Value::as_u64) {
831                Some(id) => id,
832                None => {
833                    tracing::trace!(
834                        extension = %extension_id,
835                        frame = %value,
836                        "Discarding inbound request with non-numeric id",
837                    );
838                    return;
839                }
840            };
841            let Some(method) = method_field else { return };
842            let params = value.get("params").cloned().unwrap_or(Value::Null);
843            let inbox = inbox.clone();
844            let extension_id = extension_id.to_string();
845            tokio::spawn(async move {
846                let outcome = Self::handle_inbound_request(&inbox, &method, params).await;
847                let payload = match outcome {
848                    Ok(result) => serde_json::json!({
849                        "jsonrpc": "2.0",
850                        "id": id,
851                        "result": result,
852                    }),
853                    Err((code, message)) => serde_json::json!({
854                        "jsonrpc": "2.0",
855                        "id": id,
856                        "error": {"code": code, "message": message},
857                    }),
858                };
859                let stdin_handle = inbox.inbound_stdin.lock().await.clone();
860                if let Some(stdin) = stdin_handle {
861                    let body = match serde_json::to_string(&payload) {
862                        Ok(s) => s,
863                        Err(error) => {
864                            tracing::warn!(
865                                extension = %extension_id,
866                                error = %error,
867                                "Failed to serialize inbound response",
868                            );
869                            return;
870                        }
871                    };
872                    let frame = format!("Content-Length: {}\r\n\r\n{}", body.len(), body);
873                    let mut stdin = stdin.lock().await;
874                    if let Err(error) = stdin.write_all(frame.as_bytes()).await {
875                        tracing::warn!(
876                            extension = %extension_id,
877                            error = %error,
878                            "Failed to write inbound response",
879                        );
880                        return;
881                    }
882                    if let Err(error) = stdin.flush().await {
883                        tracing::warn!(
884                            extension = %extension_id,
885                            error = %error,
886                            "Failed to flush inbound response",
887                        );
888                    }
889                } else {
890                    tracing::warn!(
891                        extension = %extension_id,
892                        "No stdin available to reply to inbound request",
893                    );
894                }
895            });
896            return;
897        }
898
899        if id_is_present {
900            let id = match id_field.and_then(Value::as_u64) {
901                Some(id) => id,
902                None => {
903                    tracing::trace!(
904                        extension = %extension_id,
905                        frame = %value,
906                        "Discarding frame with non-numeric id",
907                    );
908                    return;
909                }
910            };
911            let sender = inbox.pending.lock().await.remove(&id);
912            match sender {
913                Some(tx) => {
914                    let payload = if let Some(err) = value.get("error") {
915                        let message = err
916                            .get("message")
917                            .and_then(Value::as_str)
918                            .unwrap_or("unknown extension error")
919                            .to_string();
920                        Err(format!("Extension error: {}", message))
921                    } else {
922                        Ok(value.get("result").cloned().unwrap_or(Value::Null))
923                    };
924                    let _ = tx.send(payload);
925                }
926                None => {
927                    tracing::trace!(
928                        extension = %extension_id,
929                        id = id,
930                        "Response with unknown id (no pending request); dropping",
931                    );
932                }
933            }
934        } else if let Some(method) = value.get("method").and_then(Value::as_str) {
935            let params = value.get("params").cloned().unwrap_or(Value::Null);
936            let frame = NotificationFrame {
937                method: method.to_string(),
938                params,
939            };
940            let mut sinks = inbox.notification_sinks.lock().await;
941            if sinks.is_empty() {
942                tracing::trace!(
943                    extension = %extension_id,
944                    method = %method,
945                    "Notification with no active subscribers; dropping",
946                );
947            } else {
948                // Fan out to every live subscriber; prune any whose receiver
949                // has been dropped. `mpsc::UnboundedSender::send` only errors
950                // when the receiver is gone, so failure == dead subscriber.
951                sinks.retain(|(_, tx)| tx.send(frame.clone()).is_ok());
952            }
953        } else {
954            tracing::trace!(
955                extension = %extension_id,
956                frame = %value,
957                "Unrecognized frame; dropping",
958            );
959        }
960    }
961
962    pub fn restart_count(&self) -> usize {
963        // Lifetime total — reporting/tests expect total restarts, not the
964        // consecutive-failure budget counter (which resets on success).
965        self.total_restarts.load(Ordering::Relaxed)
966    }
967
968    /// Public for tests: set the permission set used by inbound RPC handlers
969    /// (e.g. memory.append). Called by the manager after manifest validation.
970    pub async fn set_permissions(&self, perms: crate::extensions::permissions::PermissionSet) {
971        *self.inbox.permissions.write().await = Some(perms);
972    }
973
974    /// Handle a JSON-RPC request initiated by the extension.
975    ///
976    /// Returns `Ok(result_value)` on success or `Err((code, message))` for a
977    /// JSON-RPC error response. Currently routes:
978    /// - `memory.append` (requires `memory.write`)
979    /// - `memory.query`  (requires `memory.read`)
980    ///   All other methods return -32601 (method not found).
981    #[allow(clippy::doc_lazy_continuation)]
982    async fn handle_inbound_request(
983        inbox: &Arc<Inbox>,
984        method: &str,
985        params: Value,
986    ) -> Result<Value, (i32, String)> {
987        use crate::extensions::permissions::Permission;
988        use crate::memory::store::{self, MemoryQuery};
989
990        match method {
991            "memory.append" => {
992                Self::require_permission(inbox, Permission::MemoryWrite, "memory.write").await?;
993                let namespace = Self::param_str(&params, "namespace")?;
994                Self::require_namespace_matches(inbox, &namespace).await?;
995                let content = Self::param_str(&params, "content")?;
996                let tags = match params.get("tags") {
997                    None | Some(Value::Null) => Vec::new(),
998                    Some(Value::Array(arr)) => {
999                        let mut out = Vec::with_capacity(arr.len());
1000                        for v in arr {
1001                            match v.as_str() {
1002                                Some(s) => out.push(s.to_string()),
1003                                None => {
1004                                    return Err((
1005                                        -32602,
1006                                        "tags must be an array of strings".to_string(),
1007                                    ))
1008                                }
1009                            }
1010                        }
1011                        out
1012                    }
1013                    _ => return Err((-32602, "tags must be an array of strings".to_string())),
1014                };
1015                let meta = match params.get("meta") {
1016                    None | Some(Value::Null) => None,
1017                    Some(v) => Some(v.clone()),
1018                };
1019                let record = store::new_record(namespace, content, tags, meta);
1020                let timestamp_ms = record.timestamp_ms;
1021                store::append(&record).map_err(|e| (-32000, e.to_string()))?;
1022                Ok(serde_json::json!({"ok": true, "timestamp_ms": timestamp_ms}))
1023            }
1024            "memory.query" => {
1025                Self::require_permission(inbox, Permission::MemoryRead, "memory.read").await?;
1026                let namespace = Self::param_str(&params, "namespace")?;
1027                Self::require_namespace_matches(inbox, &namespace).await?;
1028                let q = MemoryQuery {
1029                    content_contains: params
1030                        .get("content_contains")
1031                        .and_then(Value::as_str)
1032                        .map(str::to_string),
1033                    tag_prefix: params
1034                        .get("tag_prefix")
1035                        .and_then(Value::as_str)
1036                        .map(str::to_string),
1037                    since_ms: params.get("since_ms").and_then(Value::as_u64),
1038                    until_ms: params.get("until_ms").and_then(Value::as_u64),
1039                    limit: params
1040                        .get("limit")
1041                        .and_then(Value::as_u64)
1042                        .map(|n| n as usize),
1043                };
1044                let records = store::query(&namespace, &q).map_err(|e| (-32000, e.to_string()))?;
1045                Ok(serde_json::json!({"records": records}))
1046            }
1047            "config.get" => {
1048                let key = Self::param_str(&params, "key")?;
1049                Self::validate_config_key(&key)?;
1050                let value =
1051                    crate::extensions::config_store::read_plugin_config(&inbox.extension_id, &key);
1052                Ok(serde_json::json!({"value": value}))
1053            }
1054            "config.set" => {
1055                Self::require_permission(inbox, Permission::ConfigWrite, "config.write").await?;
1056                let key = Self::param_str(&params, "key")?;
1057                Self::validate_config_key(&key)?;
1058                let value = Self::param_str(&params, "value")?;
1059                crate::extensions::config_store::write_plugin_config(
1060                    &inbox.extension_id,
1061                    &key,
1062                    &value,
1063                )
1064                .map_err(|e| (-32000, e.to_string()))?;
1065                Ok(serde_json::json!({"ok": true}))
1066            }
1067            "config.subscribe" => {
1068                Self::require_permission(inbox, Permission::ConfigSubscribe, "config.subscribe")
1069                    .await?;
1070                // The long-lived watcher-to-notification bridge is wired at the manager/UI layer.
1071                // This phase exposes the authorized protocol ACK so plugins can opt in without
1072                // blocking initialize; direct store watchers are unit-tested in config_store.
1073                Ok(serde_json::json!({"ok": true}))
1074            }
1075            other => Err((-32601, format!("method not found: {other}"))),
1076        }
1077    }
1078
1079    async fn require_permission(
1080        inbox: &Arc<Inbox>,
1081        perm: crate::extensions::permissions::Permission,
1082        wire: &str,
1083    ) -> Result<(), (i32, String)> {
1084        let guard = inbox.permissions.read().await;
1085        match guard.as_ref() {
1086            Some(set) if set.has(perm) => Ok(()),
1087            _ => Err((-32602, format!("permission denied: {wire} required"))),
1088        }
1089    }
1090
1091    async fn require_namespace_matches(
1092        inbox: &Arc<Inbox>,
1093        namespace: &str,
1094    ) -> Result<(), (i32, String)> {
1095        if namespace == inbox.extension_id {
1096            Ok(())
1097        } else {
1098            Err((
1099                -32602,
1100                format!(
1101                    "namespace must equal extension id '{}' (got '{}')",
1102                    inbox.extension_id, namespace
1103                ),
1104            ))
1105        }
1106    }
1107
1108    fn param_str(params: &Value, name: &str) -> Result<String, (i32, String)> {
1109        params
1110            .get(name)
1111            .and_then(Value::as_str)
1112            .map(str::to_string)
1113            .ok_or_else(|| (-32602, format!("missing or invalid '{name}' parameter")))
1114    }
1115
1116    fn validate_config_key(key: &str) -> Result<(), (i32, String)> {
1117        let trimmed = key.trim();
1118        if trimmed.is_empty() {
1119            return Err((-32602, "config key must be non-empty".to_string()));
1120        }
1121        if trimmed.contains('.') || trimmed.contains('/') || trimmed.contains(' ') {
1122            return Err((
1123                -32602,
1124                "config key must not contain dots, slashes, or spaces".to_string(),
1125            ));
1126        }
1127        Ok(())
1128    }
1129
1130    pub async fn initialize(
1131        &self,
1132        plugin_root: Option<PathBuf>,
1133        config: Value,
1134    ) -> Result<InitializeCapabilitiesResult, String> {
1135        let params = InitializeParams {
1136            synaps_version: env!("CARGO_PKG_VERSION"),
1137            extension_protocol_version: CURRENT_EXTENSION_PROTOCOL_VERSION,
1138            plugin_id: self.id.clone(),
1139            plugin_root: plugin_root
1140                .or_else(|| self.cwd.clone())
1141                .map(|path| path.to_string_lossy().to_string()),
1142            config,
1143        };
1144        let value = self
1145            .call_no_restart(
1146                "initialize",
1147                serde_json::to_value(params).map_err(|e| e.to_string())?,
1148            )
1149            .await?;
1150        Self::parse_initialize_result(&self.id, value)
1151    }
1152
1153    fn parse_initialize_result(
1154        id: &str,
1155        value: Value,
1156    ) -> Result<InitializeCapabilitiesResult, String> {
1157        let result: InitializeResult = serde_json::from_value(value)
1158            .map_err(|e| format!("Invalid initialize response from extension '{}': {}", id, e))?;
1159        if result.protocol_version != CURRENT_EXTENSION_PROTOCOL_VERSION {
1160            return Err(format!(
1161                "Extension '{}' initialize returned unsupported protocol_version {} (supported: {})",
1162                id, result.protocol_version, CURRENT_EXTENSION_PROTOCOL_VERSION,
1163            ));
1164        }
1165        Self::validate_registered_tool_specs(id, &result.capabilities.tools)?;
1166        Self::validate_registered_provider_specs(id, &result.capabilities.providers)?;
1167        Ok(InitializeCapabilitiesResult {
1168            tools: result.capabilities.tools,
1169            providers: result.capabilities.providers,
1170            capabilities: result.capabilities.capabilities,
1171        })
1172    }
1173
1174    fn validate_registered_tool_specs(
1175        id: &str,
1176        tools: &[RegisteredExtensionToolSpec],
1177    ) -> Result<(), String> {
1178        use crate::extensions::validation::{validate_id_segment, IdValidationError};
1179        let mut names = HashSet::new();
1180        for tool in tools {
1181            let name = tool.name.trim();
1182            if let Err(err) = validate_id_segment(name) {
1183                return Err(match err {
1184                    IdValidationError::Empty => format!(
1185                        "Extension '{}' registered a tool with an empty tool name",
1186                        id
1187                    ),
1188                    IdValidationError::ContainsReserved { ch } => format!(
1189                        "Extension '{}' registered tool '{}' with invalid tool name: '{}' is reserved",
1190                        id, name, ch
1191                    ),
1192                    IdValidationError::TooLong { len, max } => format!(
1193                        "Extension '{}' registered tool '{}' with invalid tool name: must be at most {} chars (got {})",
1194                        id, name, max, len
1195                    ),
1196                    IdValidationError::ContainsWhitespace => format!(
1197                        "Extension '{}' registered tool '{}' with invalid tool name: must not contain whitespace",
1198                        id, name
1199                    ),
1200                    IdValidationError::ContainsControl { ch } => format!(
1201                        "Extension '{}' registered tool '{}' with invalid tool name: contains control character U+{:04X}",
1202                        id, name, ch as u32
1203                    ),
1204                });
1205            }
1206            if !names.insert(name.to_string()) {
1207                return Err(format!(
1208                    "Extension '{}' registered duplicate tool name '{}'",
1209                    id, name
1210                ));
1211            }
1212            if tool.description.trim().is_empty() {
1213                return Err(format!(
1214                    "Extension '{}' registered tool '{}' with an empty description",
1215                    id, name,
1216                ));
1217            }
1218            if !tool.input_schema.is_object() {
1219                return Err(format!(
1220                    "Extension '{}' registered tool '{}' with invalid input_schema: input_schema must be a JSON object",
1221                    id, name,
1222                ));
1223            }
1224        }
1225        Ok(())
1226    }
1227
1228    fn validate_registered_provider_specs(
1229        id: &str,
1230        providers: &[RegisteredProviderSpec],
1231    ) -> Result<(), String> {
1232        use crate::extensions::validation::{validate_id_segment, IdValidationError};
1233        for provider in providers {
1234            let provider_id = provider.id.trim();
1235            match validate_id_segment(provider_id) {
1236                Ok(()) => {
1237                    if !Self::is_safe_provider_id(provider_id) {
1238                        return Err(format!(
1239                            "Extension '{}' registered provider '{}' with invalid provider id",
1240                            id, provider_id
1241                        ));
1242                    }
1243                }
1244                Err(IdValidationError::Empty) => {
1245                    return Err(format!(
1246                        "Extension '{}' registered provider with empty provider id",
1247                        id
1248                    ));
1249                }
1250                Err(err) => {
1251                    return Err(format!(
1252                        "Extension '{}' registered provider '{}' with invalid provider id: {}",
1253                        id, provider_id, err
1254                    ));
1255                }
1256            }
1257            if provider.display_name.trim().is_empty() {
1258                return Err(format!(
1259                    "Extension '{}' registered provider '{}' with empty display_name",
1260                    id, provider_id,
1261                ));
1262            }
1263            if provider.description.trim().is_empty() {
1264                return Err(format!(
1265                    "Extension '{}' registered provider '{}' with empty description",
1266                    id, provider_id,
1267                ));
1268            }
1269            if provider.models.is_empty() {
1270                return Err(format!(
1271                    "Extension '{}' registered provider '{}' must declare at least one model",
1272                    id, provider_id,
1273                ));
1274            }
1275            let mut model_ids = HashSet::new();
1276            for model in &provider.models {
1277                let model_id = model.id.trim();
1278                if let Err(err) = validate_id_segment(model_id) {
1279                    return Err(match err {
1280                        IdValidationError::Empty => format!(
1281                            "Extension '{}' registered provider '{}' with empty model id",
1282                            id, provider_id
1283                        ),
1284                        IdValidationError::ContainsReserved { ch } => format!(
1285                            "Extension '{}' registered provider '{}' with invalid model id '{}': '{}' is reserved",
1286                            id, provider_id, model_id, ch
1287                        ),
1288                        IdValidationError::TooLong { len, max } => format!(
1289                            "Extension '{}' registered provider '{}' with invalid model id '{}': must be at most {} chars (got {})",
1290                            id, provider_id, model_id, max, len
1291                        ),
1292                        IdValidationError::ContainsWhitespace => format!(
1293                            "Extension '{}' registered provider '{}' with invalid model id '{}': must not contain whitespace",
1294                            id, provider_id, model_id
1295                        ),
1296                        IdValidationError::ContainsControl { ch } => format!(
1297                            "Extension '{}' registered provider '{}' with invalid model id '{}': contains control character U+{:04X}",
1298                            id, provider_id, model_id, ch as u32
1299                        ),
1300                    });
1301                }
1302                if !model_ids.insert(model_id.to_string()) {
1303                    return Err(format!(
1304                        "Extension '{}' registered provider '{}' with duplicate model id '{}'",
1305                        id, provider_id, model_id,
1306                    ));
1307                }
1308            }
1309            if let Some(config_schema) = &provider.config_schema {
1310                if !config_schema.is_object() {
1311                    return Err(format!(
1312                        "Extension '{}' registered provider '{}' with invalid config_schema: config_schema must be a JSON object",
1313                        id, provider_id,
1314                    ));
1315                }
1316            }
1317        }
1318        Ok(())
1319    }
1320
1321    fn is_safe_provider_id(id: &str) -> bool {
1322        !id.is_empty()
1323            && !id.contains(':')
1324            && id
1325                .chars()
1326                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
1327    }
1328
1329    #[doc(hidden)]
1330    pub async fn initialize_for_test(&self, plugin_root: Option<PathBuf>) -> Result<(), String> {
1331        self.initialize(plugin_root, Value::Object(Default::default()))
1332            .await
1333            .map(|_| ())
1334    }
1335
1336    async fn restart_locked(&self, state: &mut Option<ProcessState>) -> Result<(), String> {
1337        let attempted = self.restart_count.fetch_add(1, Ordering::Relaxed) + 1;
1338        self.total_restarts.fetch_add(1, Ordering::Relaxed);
1339        let max_attempts = self.restart_policy.max_attempts;
1340        if attempted > max_attempts as usize {
1341            *state = None;
1342            return Err(format!(
1343                "Extension '{}' exceeded restart limit ({})",
1344                self.id, max_attempts,
1345            ));
1346        }
1347
1348        if let Some(old) = state.take() {
1349            old.reader_handle.abort();
1350            let mut child = old.child;
1351            let _ = child.kill().await;
1352        }
1353        // Drain any stale pending entries before reusing the inbox.
1354        self.inbox
1355            .fail_all_pending("transport closed: process restarting")
1356            .await;
1357
1358        let delay = self
1359            .restart_policy
1360            .delay_for_attempt(attempted as u32)
1361            .unwrap_or_default();
1362
1363        tracing::warn!(
1364            extension = %self.id,
1365            attempt = attempted,
1366            max_attempts = max_attempts,
1367            delay_ms = delay.as_millis() as u64,
1368            "Restarting extension process after transport failure",
1369        );
1370
1371        if !delay.is_zero() {
1372            tokio::time::sleep(delay).await;
1373        }
1374
1375        *state = Some(
1376            Self::spawn_state(
1377                &self.id,
1378                &self.command,
1379                &self.args,
1380                self.cwd.as_ref(),
1381                self.inbox.clone(),
1382            )
1383            .await?,
1384        );
1385        // Reset closed flag now that we have a fresh transport
1386        self.inbox
1387            .closed
1388            .store(false, std::sync::atomic::Ordering::Release);
1389        self.initialize_locked(state).await?;
1390        // NOTE: the consecutive-failure counter is NOT reset here. A
1391        // successful handshake isn't proof of recovery — a crash-looping
1392        // extension can initialize fine and die on the next request
1393        // (always_exit fixture). The reset happens in call_inner once the
1394        // restarted process actually serves a call (preserves H-8 intent:
1395        // transient failures hours apart don't accumulate toward disable).
1396        Ok(())
1397    }
1398
1399    async fn initialize_locked(&self, state: &mut Option<ProcessState>) -> Result<(), String> {
1400        let params = InitializeParams {
1401            synaps_version: env!("CARGO_PKG_VERSION"),
1402            extension_protocol_version: CURRENT_EXTENSION_PROTOCOL_VERSION,
1403            plugin_id: self.id.clone(),
1404            plugin_root: self
1405                .cwd
1406                .clone()
1407                .map(|path| path.to_string_lossy().to_string()),
1408            config: Value::Object(Default::default()),
1409        };
1410        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
1411        let value = tokio::time::timeout(
1412            std::time::Duration::from_secs(10),
1413            self.call_once_locked(
1414                state.as_mut().expect("state should exist for initialize"),
1415                "initialize",
1416                serde_json::to_value(params).map_err(|e| e.to_string())?,
1417                id,
1418            ),
1419        )
1420        .await
1421        .map_err(|_| format!("Extension '{}' initialize timed out after 10s", self.id))??;
1422        Self::parse_initialize_result(&self.id, value).map(|_| ())
1423    }
1424
1425    /// Send a single JSON-RPC request and await the matching response,
1426    /// using the shared inbox for response delivery. The reader task runs
1427    /// concurrently and may dispatch interleaved notifications.
1428    async fn call_once_locked(
1429        &self,
1430        state: &mut ProcessState,
1431        method: &str,
1432        params: Value,
1433        id: u64,
1434    ) -> Result<Value, String> {
1435        let body = serde_json::to_string(&JsonRpcRequest {
1436            jsonrpc: "2.0",
1437            method: method.to_string(),
1438            params,
1439            id,
1440        })
1441        .map_err(|e| format!("Serialize error: {}", e))?;
1442
1443        let (tx, rx) = oneshot::channel::<Result<Value, String>>();
1444        // Check if inbox is closed before registering — if the reader already
1445        // exited, no one will ever send a response on this channel.
1446        if self.inbox.closed.load(std::sync::atomic::Ordering::Acquire) {
1447            return Err("transport closed: inbox is shut down".to_string());
1448        }
1449
1450        // Register pending BEFORE writing so the reader can route a fast
1451        // response without racing against the insert.
1452        self.inbox.pending.lock().await.insert(id, tx);
1453
1454        // Double-check: if closed was set between our check and the insert,
1455        // remove the entry and fail immediately.
1456        if self.inbox.closed.load(std::sync::atomic::Ordering::Acquire) {
1457            self.inbox.pending.lock().await.remove(&id);
1458            return Err("transport closed: inbox shut down during registration".to_string());
1459        }
1460
1461        let frame = format!("Content-Length: {}\r\n\r\n{}", body.len(), body);
1462        let write_result = {
1463            let mut stdin = state.stdin.lock().await;
1464            match stdin.write_all(frame.as_bytes()).await {
1465                Ok(()) => stdin.flush().await,
1466                Err(e) => Err(e),
1467            }
1468        };
1469        if let Err(e) = write_result {
1470            // Make sure we don't leak the pending entry if the write fails.
1471            self.inbox.pending.lock().await.remove(&id);
1472            return Err(format!("Write error: {}", e));
1473        }
1474
1475        match rx.await {
1476            Ok(payload) => payload,
1477            Err(_) => {
1478                // Sender was dropped without sending — typically because the
1479                // reader task observed EOF/error after we registered. The
1480                // reader normally sends an Err first; this branch is a
1481                // belt-and-braces fallback.
1482                self.inbox.pending.lock().await.remove(&id);
1483                Err("transport closed: response channel dropped".to_string())
1484            }
1485        }
1486    }
1487
1488    async fn call_no_restart(&self, method: &str, params: Value) -> Result<Value, String> {
1489        let _call_guard = self.call_lock.lock().await;
1490        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
1491        let mut state_guard = self.state.lock().await;
1492        if state_guard.is_none() {
1493            *state_guard = Some(
1494                Self::spawn_state(
1495                    &self.id,
1496                    &self.command,
1497                    &self.args,
1498                    self.cwd.as_ref(),
1499                    self.inbox.clone(),
1500                )
1501                .await?,
1502            );
1503        }
1504        self.call_once_locked(
1505            state_guard.as_mut().expect("state should exist"),
1506            method,
1507            params,
1508            id,
1509        )
1510        .await
1511    }
1512
1513    async fn call(&self, method: &str, params: Value) -> Result<Value, String> {
1514        let timeout_secs = if method == "tool.call" { 120 } else { 30 };
1515        let id_str = self.id.clone();
1516        let method_str = method.to_string();
1517
1518        let result = tokio::time::timeout(
1519            std::time::Duration::from_secs(timeout_secs),
1520            self.call_inner(method, params),
1521        )
1522        .await;
1523
1524        match result {
1525            Ok(inner) => inner,
1526            Err(_) => Err(format!(
1527                "Extension '{}' method '{}' timed out after {}s",
1528                id_str, method_str, timeout_secs
1529            )),
1530        }
1531    }
1532
1533    async fn call_inner(&self, method: &str, params: Value) -> Result<Value, String> {
1534        let _call_guard = self.call_lock.lock().await;
1535        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
1536        let mut state_guard = self.state.lock().await;
1537        if state_guard.is_none() {
1538            self.restart_locked(&mut state_guard).await?;
1539        }
1540
1541        let result = self
1542            .call_once_locked(
1543                state_guard
1544                    .as_mut()
1545                    .expect("state should exist after restart"),
1546                method,
1547                params.clone(),
1548                id,
1549            )
1550            .await;
1551
1552        match result {
1553            Ok(value) => {
1554                // Process served a real call — it has genuinely recovered.
1555                // Reset the consecutive-failure budget (H-8) so transient
1556                // crashes hours apart don't accumulate toward disable.
1557                self.restart_count.store(0, Ordering::Relaxed);
1558                Ok(value)
1559            }
1560            Err(first_error) => {
1561                self.restart_locked(&mut state_guard).await?;
1562                let retry_id = self.next_id.fetch_add(1, Ordering::Relaxed);
1563                self.call_once_locked(
1564                    state_guard
1565                        .as_mut()
1566                        .expect("state should exist after restart"),
1567                    method,
1568                    params,
1569                    retry_id,
1570                )
1571                .await
1572                .inspect(|_| {
1573                    // Retry succeeded post-restart — also a real recovery.
1574                    self.restart_count.store(0, Ordering::Relaxed);
1575                })
1576                .map_err(|retry_error| {
1577                    format!(
1578                        "{}; retry after restart failed: {}",
1579                        first_error, retry_error
1580                    )
1581                })
1582            }
1583        }
1584    }
1585
1586    /// Subscribe to JSON-RPC notifications emitted by the extension.
1587    ///
1588    /// Returns `(subscription_id, receiver)`. The id must be passed back to
1589    /// [`unsubscribe_notifications`] to cancel this subscription specifically
1590    /// — concurrent subscribers (e.g. background widget watchers running
1591    /// alongside `command.invoke` / `provider.stream`) coexist and each
1592    /// receives every notification frame.
1593    ///
1594    /// The receiver yields every notification frame (no `id`, has `method`)
1595    /// the extension sends until either:
1596    /// - the receiver is dropped (its slot is pruned on the next dispatch),
1597    /// - [`unsubscribe_notifications`] is called with this id, or
1598    /// - the reader observes EOF or a transport error (all subscribers are
1599    ///   dropped together).
1600    ///
1601    /// Internal API: exposed publicly with `#[doc(hidden)]` only so
1602    /// integration tests can exercise the bidirectional transport.
1603    #[doc(hidden)]
1604    pub async fn subscribe_notifications(
1605        &self,
1606    ) -> (usize, mpsc::UnboundedReceiver<NotificationFrame>) {
1607        let (tx, rx) = mpsc::unbounded_channel();
1608        let id = self
1609            .inbox
1610            .next_sink_id
1611            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1612        let mut sinks = self.inbox.notification_sinks.lock().await;
1613        // Opportunistically prune any subscribers whose receivers have been
1614        // dropped, so the Vec doesn't grow unboundedly across long-lived
1615        // extensions if callers forget to unsubscribe.
1616        sinks.retain(|(_, tx)| !tx.is_closed());
1617        sinks.push((id, tx));
1618        (id, rx)
1619    }
1620
1621    /// Drop the notification subscription with the given id, if any.
1622    /// Unknown ids are silently ignored (idempotent — safe to call on
1623    /// belt-and-braces timeout paths even after the inner future already
1624    /// unsubscribed).
1625    #[doc(hidden)]
1626    pub async fn unsubscribe_notifications(&self, id: usize) {
1627        let mut sinks = self.inbox.notification_sinks.lock().await;
1628        sinks.retain(|(sub_id, tx)| *sub_id != id && !tx.is_closed());
1629    }
1630
1631    /// Forward one notification frame received during `command.invoke`.
1632    ///
1633    /// Routes:
1634    /// - `command.output` whose `request_id` matches → sink as `Output(event)`
1635    ///   (other request_ids are ignored — concurrent invocations must use distinct ids
1636    ///   and may overlap in the same `subscribe_notifications` channel; mismatched
1637    ///   frames are dropped here intentionally).
1638    /// - `task.start|update|log|done` → sink as `Task(event)` regardless of request_id.
1639    /// - Anything else → logged at trace and dropped.
1640    pub(crate) fn forward_invoke_command_frame(
1641        extension_id: &str,
1642        request_id: &str,
1643        sink: &mpsc::UnboundedSender<crate::extensions::runtime::InvokeCommandEvent>,
1644        sink_open: &mut bool,
1645        frame: NotificationFrame,
1646    ) -> bool {
1647        use crate::extensions::commands::parse_command_output;
1648        use crate::extensions::runtime::InvokeCommandEvent;
1649        use crate::extensions::tasks::{is_task_method, parse_task_event};
1650
1651        let mut saw_done = false;
1652        if frame.method == "command.output" {
1653            match parse_command_output(&frame.params) {
1654                Ok(parsed) if parsed.request_id == request_id => {
1655                    if matches!(
1656                        parsed.event,
1657                        crate::extensions::commands::CommandOutputEvent::Done
1658                    ) {
1659                        saw_done = true;
1660                    }
1661                    if *sink_open && sink.send(InvokeCommandEvent::Output(parsed.event)).is_err() {
1662                        *sink_open = false;
1663                    }
1664                }
1665                Ok(_) => {
1666                    // Different request_id — ignore (logged at trace for diagnostics).
1667                    tracing::trace!(
1668                        extension = %extension_id,
1669                        "Ignoring command.output for unrelated request_id",
1670                    );
1671                }
1672                Err(error) => {
1673                    tracing::warn!(
1674                        extension = %extension_id,
1675                        error = %error,
1676                        params = %frame.params,
1677                        "Skipping malformed command.output notification",
1678                    );
1679                }
1680            }
1681        } else if is_task_method(&frame.method) {
1682            match parse_task_event(&frame.method, &frame.params) {
1683                Ok(event) => {
1684                    if *sink_open && sink.send(InvokeCommandEvent::Task(event)).is_err() {
1685                        *sink_open = false;
1686                    }
1687                }
1688                Err(error) => {
1689                    tracing::warn!(
1690                        extension = %extension_id,
1691                        method = %frame.method,
1692                        error = %error,
1693                        params = %frame.params,
1694                        "Skipping malformed task notification",
1695                    );
1696                }
1697            }
1698        } else {
1699            tracing::trace!(
1700                extension = %extension_id,
1701                method = %frame.method,
1702                "Ignoring non-command/task notification during command.invoke",
1703            );
1704        }
1705        saw_done
1706    }
1707
1708    /// Forward one notification frame received during `provider.stream`.
1709    ///
1710    /// - Frames whose method is not `provider.stream.event` are ignored (logged at trace).
1711    /// - Malformed event params are logged at warn and skipped (do not abort the call).
1712    /// - If the caller's sink has been closed, sets `sink_open = false` and stops forwarding,
1713    ///   but the in-flight request is still allowed to complete.
1714    fn forward_provider_stream_frame(
1715        extension_id: &str,
1716        sink: &mpsc::UnboundedSender<ProviderStreamEvent>,
1717        sink_open: &mut bool,
1718        frame: NotificationFrame,
1719    ) {
1720        if frame.method != "provider.stream.event" {
1721            tracing::trace!(
1722                extension = %extension_id,
1723                method = %frame.method,
1724                "Ignoring non-stream notification during provider.stream",
1725            );
1726            return;
1727        }
1728        match parse_provider_stream_event(&frame.params) {
1729            Ok(event) => {
1730                if *sink_open && sink.send(event).is_err() {
1731                    *sink_open = false;
1732                }
1733            }
1734            Err(error) => {
1735                tracing::warn!(
1736                    extension = %extension_id,
1737                    error = %error,
1738                    params = %frame.params,
1739                    "Skipping malformed provider.stream.event notification",
1740                );
1741            }
1742        }
1743    }
1744}
1745
1746#[async_trait]
1747impl ExtensionHandler for ProcessExtension {
1748    fn id(&self) -> &str {
1749        &self.id
1750    }
1751
1752    async fn call_tool(&self, name: &str, input: Value) -> Result<Value, String> {
1753        self.call(
1754            "tool.call",
1755            serde_json::json!({
1756                "name": name,
1757                "input": input,
1758            }),
1759        )
1760        .await
1761    }
1762
1763    async fn provider_complete(
1764        &self,
1765        params: ProviderCompleteParams,
1766    ) -> Result<ProviderCompleteResult, String> {
1767        let value = tokio::time::timeout(
1768            std::time::Duration::from_secs(60),
1769            self.call(
1770                "provider.complete",
1771                serde_json::to_value(params).map_err(|e| e.to_string())?,
1772            ),
1773        )
1774        .await
1775        .map_err(|_| format!("Extension '{}' provider.complete timed out", self.id))??;
1776        let result: ProviderCompleteResult = serde_json::from_value(value).map_err(|e| {
1777            format!(
1778                "Invalid provider.complete response from extension '{}': {}",
1779                self.id, e
1780            )
1781        })?;
1782        if result.content.is_empty() {
1783            return Err(format!(
1784                "Extension '{}' provider.complete returned empty content",
1785                self.id
1786            ));
1787        }
1788        Ok(result)
1789    }
1790
1791    async fn provider_stream(
1792        &self,
1793        params: ProviderCompleteParams,
1794        sink: tokio::sync::mpsc::UnboundedSender<ProviderStreamEvent>,
1795    ) -> Result<ProviderCompleteResult, String> {
1796        // Subscribe BEFORE issuing the request so we don't miss early
1797        // notifications that may arrive before `call(...)` even starts polling.
1798        let (sub_id, mut rx) = self.subscribe_notifications().await;
1799        let params_value = serde_json::to_value(params).map_err(|e| e.to_string())?;
1800
1801        let extension_id = self.id.clone();
1802        let stream_future = async {
1803            let mut call_fut = Box::pin(self.call("provider.stream", params_value));
1804            let mut sink_open = true;
1805            let response = loop {
1806                tokio::select! {
1807                    response = &mut call_fut => break response,
1808                    Some(frame) = rx.recv() => {
1809                        Self::forward_provider_stream_frame(
1810                            &extension_id, &sink, &mut sink_open, frame,
1811                        );
1812                    }
1813                }
1814            };
1815            // Response received: drop our subscription so this receiver yields
1816            // `None` once buffered frames are drained, then flush any
1817            // remaining notifications before returning. Other concurrent
1818            // subscribers (if any) are untouched.
1819            self.unsubscribe_notifications(sub_id).await;
1820            while let Some(frame) = rx.recv().await {
1821                Self::forward_provider_stream_frame(&extension_id, &sink, &mut sink_open, frame);
1822            }
1823            response
1824        };
1825
1826        let outcome = tokio::time::timeout(std::time::Duration::from_secs(60), stream_future).await;
1827
1828        // Belt-and-braces: ensure our subscription is cleared on timeout too.
1829        // Idempotent if the inner future already unsubscribed.
1830        self.unsubscribe_notifications(sub_id).await;
1831
1832        let value =
1833            outcome.map_err(|_| format!("Extension '{}' provider.stream timed out", self.id))??;
1834
1835        let result: ProviderCompleteResult = serde_json::from_value(value).map_err(|e| {
1836            format!(
1837                "Invalid provider.stream response from extension '{}': {}",
1838                self.id, e
1839            )
1840        })?;
1841        // NOTE: empty `content` is permitted for streaming — output may have
1842        // been delivered entirely via TextDelta notifications.
1843        Ok(result)
1844    }
1845
1846    async fn invoke_command(
1847        &self,
1848        command: &str,
1849        args: Vec<String>,
1850        request_id: &str,
1851        sink: tokio::sync::mpsc::UnboundedSender<crate::extensions::runtime::InvokeCommandEvent>,
1852    ) -> Result<Value, String> {
1853        // Subscribe before issuing the request so we don't miss early events.
1854        let (sub_id, mut rx) = self.subscribe_notifications().await;
1855        let params = serde_json::json!({
1856            "command": command,
1857            "args": args,
1858            "request_id": request_id,
1859        });
1860
1861        let extension_id = self.id.clone();
1862        let request_id_owned = request_id.to_string();
1863        let invoke_future = async {
1864            let mut call_fut = Box::pin(self.call("command.invoke", params));
1865            let mut sink_open = true;
1866            let response = loop {
1867                tokio::select! {
1868                    response = &mut call_fut => break response,
1869                    Some(frame) = rx.recv() => {
1870                        let _ = Self::forward_invoke_command_frame(
1871                            &extension_id, &request_id_owned, &sink, &mut sink_open, frame,
1872                        );
1873                    }
1874                }
1875            };
1876            // Drain any notifications already buffered after the response lands, but
1877            // do not wait for the subscriber channel to close (that would deadlock
1878            // while this invocation still owns `rx`).
1879            self.unsubscribe_notifications(sub_id).await;
1880            while let Ok(frame) = rx.try_recv() {
1881                let _ = Self::forward_invoke_command_frame(
1882                    &extension_id,
1883                    &request_id_owned,
1884                    &sink,
1885                    &mut sink_open,
1886                    frame,
1887                );
1888            }
1889            response
1890        };
1891
1892        let outcome =
1893            tokio::time::timeout(std::time::Duration::from_secs(120), invoke_future).await;
1894
1895        // Belt-and-braces: ensure our subscription is cleared on timeout too.
1896        // Idempotent if the inner future already unsubscribed.
1897        self.unsubscribe_notifications(sub_id).await;
1898
1899        outcome.map_err(|_| format!("Extension '{}' command.invoke timed out", self.id))?
1900    }
1901
1902    async fn handle(&self, event: &HookEvent) -> HookResult {
1903        let params = serde_json::to_value(event).unwrap_or(Value::Null);
1904        match tokio::time::timeout(
1905            std::time::Duration::from_secs(5),
1906            self.call("hook.handle", params),
1907        )
1908        .await
1909        {
1910            Ok(Ok(value)) => match serde_json::from_value(value.clone()) {
1911                Ok(result) => result,
1912                Err(error) => {
1913                    tracing::warn!(
1914                        extension = %self.id,
1915                        error = %error,
1916                        response = %value,
1917                        "Extension hook handler returned invalid result",
1918                    );
1919                    match value.get("action").and_then(Value::as_str) {
1920                        // A malformed `modify` is dangerous — it would let an
1921                        // unknown/partial input through. Fail safe by blocking.
1922                        Some("modify") => HookResult::Block {
1923                            reason: "Extension returned malformed modify result".to_string(),
1924                        },
1925                        // A malformed `replace` is safe to ignore: the original
1926                        // tool output is preserved by emit_after_tool_call.
1927                        // Explicit arm so this intent can't be silently changed
1928                        // by a future edit to the catch-all below.
1929                        Some("replace") => HookResult::Continue,
1930                        _ => HookResult::Continue,
1931                    }
1932                }
1933            },
1934            Ok(Err(e)) => {
1935                tracing::warn!(
1936                    extension = %self.id,
1937                    error = %e,
1938                    "Extension hook handler failed — continuing",
1939                );
1940                HookResult::Continue
1941            }
1942            Err(_) => {
1943                tracing::warn!(
1944                    extension = %self.id,
1945                    timeout_secs = 5,
1946                    "Extension hook handler timed out — continuing",
1947                );
1948                HookResult::Continue
1949            }
1950        }
1951    }
1952
1953    async fn get_info(&self) -> Result<crate::extensions::info::PluginInfo, String> {
1954        let value = tokio::time::timeout(
1955            std::time::Duration::from_secs(5),
1956            self.call("info.get", Value::Null),
1957        )
1958        .await
1959        .map_err(|_| format!("Extension '{}' info.get timed out", self.id))??;
1960        serde_json::from_value(value).map_err(|e| {
1961            format!(
1962                "Invalid info.get response from extension '{}': {}",
1963                self.id, e
1964            )
1965        })
1966    }
1967
1968    async fn sidecar_spawn_args(&self) -> Result<crate::sidecar::spawn::SidecarSpawnArgs, String> {
1969        let value = tokio::time::timeout(
1970            std::time::Duration::from_secs(5),
1971            self.call("sidecar.spawn_args", Value::Null),
1972        )
1973        .await
1974        .map_err(|_| format!("Extension '{}' sidecar.spawn_args timed out", self.id))??;
1975        serde_json::from_value(value).map_err(|e| {
1976            format!(
1977                "Invalid sidecar.spawn_args response from extension '{}': {}",
1978                self.id, e
1979            )
1980        })
1981    }
1982
1983    async fn settings_editor_open(&self, category: &str, field: &str) -> Result<Value, String> {
1984        let params = crate::extensions::settings_editor::SettingsEditorOpenParams {
1985            category: category.to_string(),
1986            field: field.to_string(),
1987        };
1988        tokio::time::timeout(
1989            std::time::Duration::from_secs(5),
1990            self.call(
1991                "settings.editor.open",
1992                serde_json::to_value(params).map_err(|e| e.to_string())?,
1993            ),
1994        )
1995        .await
1996        .map_err(|_| format!("Extension '{}' settings.editor.open timed out", self.id))?
1997    }
1998
1999    async fn settings_editor_key(
2000        &self,
2001        category: &str,
2002        field: &str,
2003        key: &str,
2004    ) -> Result<Value, String> {
2005        let mut params = serde_json::to_value(
2006            crate::extensions::settings_editor::SettingsEditorKeyParams {
2007                key: key.to_string(),
2008            },
2009        )
2010        .map_err(|e| e.to_string())?;
2011        if let Some(obj) = params.as_object_mut() {
2012            obj.insert("category".to_string(), Value::String(category.to_string()));
2013            obj.insert("field".to_string(), Value::String(field.to_string()));
2014        }
2015        tokio::time::timeout(
2016            std::time::Duration::from_secs(5),
2017            self.call("settings.editor.key", params),
2018        )
2019        .await
2020        .map_err(|_| format!("Extension '{}' settings.editor.key timed out", self.id))?
2021    }
2022
2023    async fn settings_editor_commit(
2024        &self,
2025        category: &str,
2026        field: &str,
2027        value: Value,
2028    ) -> Result<Value, String> {
2029        let params = serde_json::json!({
2030            "category": category,
2031            "field": field,
2032            "value": value,
2033        });
2034        tokio::time::timeout(
2035            std::time::Duration::from_secs(5),
2036            self.call("settings.editor.commit", params),
2037        )
2038        .await
2039        .map_err(|_| format!("Extension '{}' settings.editor.commit timed out", self.id))?
2040    }
2041
2042    async fn shutdown(&self) {
2043        let _ = tokio::time::timeout(
2044            std::time::Duration::from_millis(500),
2045            self.call("shutdown", Value::Null),
2046        )
2047        .await;
2048
2049        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
2050        let mut state_guard = self.state.lock().await;
2051        if let Some(state) = state_guard.take() {
2052            state.reader_handle.abort();
2053            let mut child = state.child;
2054            let _ = child.kill().await;
2055        }
2056        // Drop all active notification subscribers and signal pending callers.
2057        self.inbox.notification_sinks.lock().await.clear();
2058        self.inbox
2059            .fail_all_pending("transport closed: extension shutdown")
2060            .await;
2061    }
2062
2063    async fn subscribe_notifications(
2064        &self,
2065    ) -> (
2066        usize,
2067        tokio::sync::mpsc::UnboundedReceiver<NotificationFrame>,
2068    ) {
2069        ProcessExtension::subscribe_notifications(self).await
2070    }
2071
2072    async fn restart_count(&self) -> usize {
2073        self.restart_count()
2074    }
2075
2076    async fn health(&self) -> ExtensionHealth {
2077        // Exhaustion is judged on the consecutive-failure counter (resets on
2078        // successful restart); Degraded is judged on lifetime restarts so a
2079        // recovered-but-previously-crashed extension doesn't report Running.
2080        let consecutive = self.restart_count.load(Ordering::Relaxed);
2081        let lifetime = self.total_restarts.load(Ordering::Relaxed);
2082        let max = self.restart_policy.max_attempts as usize;
2083        if consecutive >= max {
2084            ExtensionHealth::Failed
2085        } else if lifetime > 0 {
2086            // Within budget. If the state slot is currently empty, we're
2087            // mid-restart; otherwise the process is alive but has previously
2088            // crashed at least once.
2089            let state_alive = self.state.try_lock().map(|g| g.is_some()).unwrap_or(true);
2090            if state_alive {
2091                ExtensionHealth::Degraded
2092            } else {
2093                ExtensionHealth::Restarting
2094            }
2095        } else {
2096            ExtensionHealth::Running
2097        }
2098    }
2099}
2100
2101#[cfg(test)]
2102mod stream_event_tests {
2103    use super::*;
2104    use serde_json::json;
2105
2106    #[test]
2107    fn parses_text_delta_with_delta_key() {
2108        let v = json!({"type": "text", "delta": "hi"});
2109        assert_eq!(
2110            parse_provider_stream_event(&v).unwrap(),
2111            ProviderStreamEvent::TextDelta { text: "hi".into() }
2112        );
2113    }
2114
2115    #[test]
2116    fn parses_text_delta_with_text_key() {
2117        let v = json!({"type": "text", "text": "hi"});
2118        assert_eq!(
2119            parse_provider_stream_event(&v).unwrap(),
2120            ProviderStreamEvent::TextDelta { text: "hi".into() }
2121        );
2122    }
2123
2124    #[test]
2125    fn parses_thinking_delta() {
2126        let v = json!({"type": "thinking", "delta": "hmm"});
2127        assert_eq!(
2128            parse_provider_stream_event(&v).unwrap(),
2129            ProviderStreamEvent::ThinkingDelta { text: "hmm".into() }
2130        );
2131        let v2 = json!({"type": "thinking", "text": "hmm"});
2132        assert_eq!(
2133            parse_provider_stream_event(&v2).unwrap(),
2134            ProviderStreamEvent::ThinkingDelta { text: "hmm".into() }
2135        );
2136    }
2137
2138    #[test]
2139    fn parses_tool_use() {
2140        let v = json!({
2141            "type": "tool_use",
2142            "id": "t1",
2143            "name": "echo",
2144            "input": {"x": 1}
2145        });
2146        assert_eq!(
2147            parse_provider_stream_event(&v).unwrap(),
2148            ProviderStreamEvent::ToolUse {
2149                id: "t1".into(),
2150                name: "echo".into(),
2151                input: json!({"x": 1}),
2152            }
2153        );
2154    }
2155
2156    #[test]
2157    fn tool_use_input_defaults_to_empty_object() {
2158        let v = json!({"type": "tool_use", "id": "t1", "name": "echo"});
2159        assert_eq!(
2160            parse_provider_stream_event(&v).unwrap(),
2161            ProviderStreamEvent::ToolUse {
2162                id: "t1".into(),
2163                name: "echo".into(),
2164                input: json!({}),
2165            }
2166        );
2167    }
2168
2169    #[test]
2170    fn parses_usage_strips_type() {
2171        let v = json!({"type": "usage", "input_tokens": 5, "output_tokens": 7});
2172        assert_eq!(
2173            parse_provider_stream_event(&v).unwrap(),
2174            ProviderStreamEvent::Usage {
2175                usage: json!({"input_tokens": 5, "output_tokens": 7})
2176            }
2177        );
2178    }
2179
2180    #[test]
2181    fn parses_error() {
2182        let v = json!({"type": "error", "message": "boom"});
2183        assert_eq!(
2184            parse_provider_stream_event(&v).unwrap(),
2185            ProviderStreamEvent::Error {
2186                message: "boom".into()
2187            }
2188        );
2189    }
2190
2191    #[test]
2192    fn parses_done() {
2193        let v = json!({"type": "done"});
2194        assert_eq!(
2195            parse_provider_stream_event(&v).unwrap(),
2196            ProviderStreamEvent::Done
2197        );
2198    }
2199
2200    #[test]
2201    fn nested_event_shape_matches_flat() {
2202        let flat = json!({"type": "text", "delta": "hi"});
2203        let nested = json!({"event": {"type": "text", "delta": "hi"}});
2204        assert_eq!(
2205            parse_provider_stream_event(&flat).unwrap(),
2206            parse_provider_stream_event(&nested).unwrap()
2207        );
2208    }
2209
2210    #[test]
2211    fn missing_type_errors() {
2212        let v = json!({"delta": "hi"});
2213        let err = parse_provider_stream_event(&v).unwrap_err();
2214        assert!(err.contains("missing type"), "got: {err}");
2215    }
2216
2217    #[test]
2218    fn unknown_type_errors_with_type() {
2219        let v = json!({"type": "wat"});
2220        let err = parse_provider_stream_event(&v).unwrap_err();
2221        assert!(err.contains("wat"), "got: {err}");
2222    }
2223
2224    #[test]
2225    fn tool_use_missing_id_errors() {
2226        let v = json!({"type": "tool_use", "name": "echo"});
2227        let err = parse_provider_stream_event(&v).unwrap_err();
2228        assert!(err.contains("id"), "got: {err}");
2229    }
2230
2231    #[test]
2232    fn tool_use_missing_name_errors() {
2233        let v = json!({"type": "tool_use", "id": "t1"});
2234        let err = parse_provider_stream_event(&v).unwrap_err();
2235        assert!(err.contains("name"), "got: {err}");
2236    }
2237
2238    #[test]
2239    fn tool_use_empty_id_errors() {
2240        let v = json!({"type": "tool_use", "id": "", "name": "echo"});
2241        assert!(parse_provider_stream_event(&v).is_err());
2242    }
2243
2244    #[test]
2245    fn tool_use_empty_name_errors() {
2246        let v = json!({"type": "tool_use", "id": "t1", "name": ""});
2247        assert!(parse_provider_stream_event(&v).is_err());
2248    }
2249
2250    #[test]
2251    fn tool_use_non_object_input_errors() {
2252        let v = json!({"type": "tool_use", "id": "t1", "name": "echo", "input": "nope"});
2253        let err = parse_provider_stream_event(&v).unwrap_err();
2254        assert!(err.contains("input"), "got: {err}");
2255    }
2256
2257    #[test]
2258    fn text_missing_delta_and_text_errors() {
2259        let v = json!({"type": "text"});
2260        let err = parse_provider_stream_event(&v).unwrap_err();
2261        assert!(err.contains("delta") || err.contains("text"), "got: {err}");
2262    }
2263
2264    #[test]
2265    fn error_missing_message_errors() {
2266        let v = json!({"type": "error"});
2267        assert!(parse_provider_stream_event(&v).is_err());
2268    }
2269
2270    #[test]
2271    fn error_empty_message_errors() {
2272        let v = json!({"type": "error", "message": ""});
2273        assert!(parse_provider_stream_event(&v).is_err());
2274    }
2275}
2276
2277#[cfg(test)]
2278mod restart_policy_tests {
2279    use super::*;
2280
2281    #[tokio::test]
2282    async fn restart_policy_default_max_attempts_is_3() {
2283        // Use a command that won't actually run; we only need the struct.
2284        // Since `spawn` actually launches the process, use a trivial echoer
2285        // and immediately shut it down. Falls back to /bin/cat which reads
2286        // from stdin and stays alive.
2287        let ext = ProcessExtension::spawn("policy-test", "/bin/cat", &[])
2288            .await
2289            .expect("spawn /bin/cat");
2290        assert_eq!(ext.restart_policy.max_attempts, 3);
2291        ext.shutdown().await;
2292    }
2293
2294    #[tokio::test]
2295    async fn with_restart_policy_overrides_default() {
2296        let ext = ProcessExtension::spawn("policy-test-override", "/bin/cat", &[])
2297            .await
2298            .expect("spawn /bin/cat");
2299        let custom = RestartPolicy {
2300            max_attempts: 7,
2301            ..RestartPolicy::default()
2302        };
2303        let ext = ext.with_restart_policy(custom);
2304        assert_eq!(ext.restart_policy.max_attempts, 7);
2305        ext.shutdown().await;
2306    }
2307}
2308
2309#[cfg(test)]
2310mod capture_validator_tests {
2311    use super::*;
2312    use crate::extensions::permissions::{Permission, PermissionSet};
2313
2314    fn perms_with(grants: &[Permission]) -> PermissionSet {
2315        let mut p = PermissionSet::new();
2316        for g in grants {
2317            p.grant(*g);
2318        }
2319        p
2320    }
2321
2322    fn cap(kind: &str, name: &str, perms: &[&str]) -> CapabilityDeclaration {
2323        CapabilityDeclaration {
2324            kind: kind.to_string(),
2325            name: name.to_string(),
2326            permissions: perms.iter().map(|p| p.to_string()).collect(),
2327            params: serde_json::Value::Null,
2328        }
2329    }
2330
2331    #[test]
2332    fn capability_validator_rejects_empty_kind() {
2333        let d = cap("   ", "Sample", &["audio.input"]);
2334        let perms = perms_with(&[Permission::AudioInput]);
2335        let err = validate_capability(&d, &perms).unwrap_err();
2336        assert!(err.contains("kind"), "got: {}", err);
2337    }
2338
2339    #[test]
2340    fn capability_validator_rejects_empty_name() {
2341        let d = cap("capture", "   ", &["audio.input"]);
2342        let perms = perms_with(&[Permission::AudioInput]);
2343        let err = validate_capability(&d, &perms).unwrap_err();
2344        assert!(err.contains("name"), "got: {}", err);
2345    }
2346
2347    #[test]
2348    fn capability_validator_rejects_unknown_permission_string() {
2349        let d = cap("capture", "Sample", &["audio.telepathy"]);
2350        let perms = perms_with(&[Permission::AudioInput, Permission::AudioOutput]);
2351        let err = validate_capability(&d, &perms).unwrap_err();
2352        assert!(
2353            err.contains("unknown permission") && err.contains("audio.telepathy"),
2354            "got: {}",
2355            err,
2356        );
2357    }
2358
2359    #[test]
2360    fn capability_validator_requires_every_declared_permission() {
2361        let d = cap("capture", "Sample", &["audio.input"]);
2362        let perms = perms_with(&[]);
2363        let err = validate_capability(&d, &perms).unwrap_err();
2364        assert!(
2365            err.contains("audio.input") && err.contains("not granted"),
2366            "got: {}",
2367            err,
2368        );
2369    }
2370
2371    #[test]
2372    fn capability_validator_accepts_when_all_permissions_granted() {
2373        let d = cap("capture", "Sample", &["audio.input", "audio.output"]);
2374        let perms = perms_with(&[Permission::AudioInput, Permission::AudioOutput]);
2375        validate_capability(&d, &perms).expect("should validate");
2376    }
2377
2378    #[test]
2379    fn capability_validator_accepts_no_permissions() {
2380        // Capabilities that genuinely don't need any permission (e.g. a
2381        // pure-CPU OCR sidecar that takes images via stdin) should
2382        // validate cleanly with an empty permission list.
2383        let d = cap("ocr", "Tesseract", &[]);
2384        let perms = perms_with(&[]);
2385        validate_capability(&d, &perms).expect("should validate");
2386    }
2387
2388    #[test]
2389    fn capability_validator_does_not_branch_on_kind() {
2390        // Two capabilities with identical permission requirements but
2391        // different `kind` values both validate identically — proves
2392        // the validator does not enumerate kinds.
2393        let perms = perms_with(&[Permission::AudioInput]);
2394        for kind in ["capture", "ocr", "agent", "foot_pedal", "eeg"] {
2395            let d = cap(kind, "Anything", &["audio.input"]);
2396            validate_capability(&d, &perms).expect("should validate");
2397        }
2398    }
2399}
2400
2401#[cfg(test)]
2402mod invoke_command_dispatch_tests {
2403    //! Phase B Phase 2/3 — exercise the notification dispatcher used by
2404    //! `command.invoke`. Spawning a real subprocess is not required: we feed
2405    //! `NotificationFrame`s directly through `forward_invoke_command_frame`
2406    //! and assert the sink ordering.
2407    use super::*;
2408    use crate::extensions::commands::CommandOutputEvent;
2409    use crate::extensions::runtime::InvokeCommandEvent;
2410    use crate::extensions::tasks::{TaskEvent, TaskKind};
2411    use serde_json::json;
2412    use tokio::sync::mpsc;
2413
2414    fn frame(method: &str, params: serde_json::Value) -> NotificationFrame {
2415        NotificationFrame {
2416            method: method.to_string(),
2417            params,
2418        }
2419    }
2420
2421    #[test]
2422    fn forwards_mixed_event_stream_in_order() {
2423        let (tx, mut rx) = mpsc::unbounded_channel::<InvokeCommandEvent>();
2424        let mut open = true;
2425        let frames = vec![
2426            frame(
2427                "command.output",
2428                json!({"request_id":"r1","event":{"kind":"text","content":"A"}}),
2429            ),
2430            frame(
2431                "task.start",
2432                json!({"id":"dl","label":"Downloading","kind":"download"}),
2433            ),
2434            frame("task.update", json!({"id":"dl","current":50,"total":100})),
2435            frame(
2436                "command.output",
2437                json!({"request_id":"r1","event":{"kind":"system","content":"working"}}),
2438            ),
2439            frame("task.done", json!({"id":"dl"})),
2440            frame(
2441                "command.output",
2442                json!({"request_id":"r1","event":{"kind":"done"}}),
2443            ),
2444        ];
2445
2446        let mut saw_done = false;
2447        for f in frames {
2448            saw_done |=
2449                ProcessExtension::forward_invoke_command_frame("ext-test", "r1", &tx, &mut open, f);
2450        }
2451        drop(tx);
2452        assert!(saw_done, "should have observed the command Done marker");
2453
2454        let mut events = Vec::new();
2455        while let Ok(ev) = rx.try_recv() {
2456            events.push(ev);
2457        }
2458        assert_eq!(events.len(), 6);
2459        assert_eq!(
2460            events[0],
2461            InvokeCommandEvent::Output(CommandOutputEvent::Text {
2462                content: "A".into()
2463            })
2464        );
2465        assert!(matches!(
2466            events[1],
2467            InvokeCommandEvent::Task(TaskEvent::Start {
2468                kind: TaskKind::Download,
2469                ..
2470            })
2471        ));
2472        assert!(matches!(
2473            events[2],
2474            InvokeCommandEvent::Task(TaskEvent::Update { .. })
2475        ));
2476        assert!(matches!(
2477            events[3],
2478            InvokeCommandEvent::Output(CommandOutputEvent::System { .. })
2479        ));
2480        assert!(matches!(
2481            events[4],
2482            InvokeCommandEvent::Task(TaskEvent::Done { error: None, .. })
2483        ));
2484        assert_eq!(
2485            events[5],
2486            InvokeCommandEvent::Output(CommandOutputEvent::Done)
2487        );
2488    }
2489
2490    #[test]
2491    fn ignores_command_output_for_unrelated_request_id() {
2492        let (tx, mut rx) = mpsc::unbounded_channel::<InvokeCommandEvent>();
2493        let mut open = true;
2494        ProcessExtension::forward_invoke_command_frame(
2495            "ext",
2496            "r1",
2497            &tx,
2498            &mut open,
2499            frame(
2500                "command.output",
2501                json!({"request_id":"other","event":{"kind":"text","content":"x"}}),
2502            ),
2503        );
2504        drop(tx);
2505        assert!(rx.try_recv().is_err());
2506    }
2507
2508    #[test]
2509    fn skips_malformed_command_output_without_aborting() {
2510        let (tx, mut rx) = mpsc::unbounded_channel::<InvokeCommandEvent>();
2511        let mut open = true;
2512        // Missing 'kind'
2513        ProcessExtension::forward_invoke_command_frame(
2514            "ext",
2515            "r1",
2516            &tx,
2517            &mut open,
2518            frame("command.output", json!({"request_id":"r1","event":{}})),
2519        );
2520        // Followed by a good event — must still be delivered.
2521        ProcessExtension::forward_invoke_command_frame(
2522            "ext",
2523            "r1",
2524            &tx,
2525            &mut open,
2526            frame(
2527                "command.output",
2528                json!({"request_id":"r1","event":{"kind":"done"}}),
2529            ),
2530        );
2531        drop(tx);
2532        let ev = rx.try_recv().unwrap();
2533        assert_eq!(ev, InvokeCommandEvent::Output(CommandOutputEvent::Done));
2534        assert!(rx.try_recv().is_err());
2535    }
2536
2537    #[test]
2538    fn task_events_pass_through_regardless_of_request_id() {
2539        let (tx, mut rx) = mpsc::unbounded_channel::<InvokeCommandEvent>();
2540        let mut open = true;
2541        ProcessExtension::forward_invoke_command_frame(
2542            "ext",
2543            "r1",
2544            &tx,
2545            &mut open,
2546            frame("task.log", json!({"id":"abc","line":"..."})),
2547        );
2548        drop(tx);
2549        match rx.try_recv().unwrap() {
2550            InvokeCommandEvent::Task(TaskEvent::Log { id, line }) => {
2551                assert_eq!(id, "abc");
2552                assert_eq!(line, "...");
2553            }
2554            other => panic!("unexpected: {other:?}"),
2555        }
2556    }
2557
2558    #[test]
2559    fn unrelated_methods_are_dropped() {
2560        let (tx, mut rx) = mpsc::unbounded_channel::<InvokeCommandEvent>();
2561        let mut open = true;
2562        ProcessExtension::forward_invoke_command_frame(
2563            "ext",
2564            "r1",
2565            &tx,
2566            &mut open,
2567            frame("provider.stream.event", json!({"type":"text","delta":"x"})),
2568        );
2569        drop(tx);
2570        assert!(rx.try_recv().is_err());
2571    }
2572}