From fbd20ad6e488fb3b9efc1612fdffee6e672976c1 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sun, 14 Jun 2026 02:43:45 +0300 Subject: feat: add plugin v1 system Add manifest-driven plugin actions, event hooks, pane entrypoints, link handlers, and host APIs. Support plugin link, GitHub install, build commands, installed plugin registry, pane movement, runtime context, and plugin docs. --- src/api/schema/events.rs | 474 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 474 insertions(+) create mode 100644 src/api/schema/events.rs (limited to 'src/api/schema/events.rs') diff --git a/src/api/schema/events.rs b/src/api/schema/events.rs new file mode 100644 index 00000000..353f75b9 --- /dev/null +++ b/src/api/schema/events.rs @@ -0,0 +1,474 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::common::{AgentStatus, ReadSource}; +use super::panes::{PaneInfo, PaneReadResult}; +use super::tabs::TabInfo; +use super::workspaces::WorkspaceInfo; +use super::worktrees::WorktreeInfo; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EventsSubscribeParams { + pub subscriptions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum Subscription { + #[serde(rename = "workspace.created")] + WorkspaceCreated {}, + #[serde(rename = "workspace.updated")] + WorkspaceUpdated {}, + #[serde(rename = "workspace.renamed")] + WorkspaceRenamed {}, + #[serde(rename = "workspace.closed")] + WorkspaceClosed {}, + #[serde(rename = "workspace.focused")] + WorkspaceFocused {}, + #[serde(rename = "worktree.created")] + WorktreeCreated {}, + #[serde(rename = "worktree.opened")] + WorktreeOpened {}, + #[serde(rename = "worktree.removed")] + WorktreeRemoved {}, + #[serde(rename = "tab.created")] + TabCreated {}, + #[serde(rename = "tab.closed")] + TabClosed {}, + #[serde(rename = "tab.focused")] + TabFocused {}, + #[serde(rename = "tab.renamed")] + TabRenamed {}, + #[serde(rename = "pane.created")] + PaneCreated {}, + #[serde(rename = "pane.closed")] + PaneClosed {}, + #[serde(rename = "pane.focused")] + PaneFocused {}, + #[serde(rename = "pane.moved")] + PaneMoved {}, + #[serde(rename = "pane.exited")] + PaneExited {}, + #[serde(rename = "pane.agent_detected")] + PaneAgentDetected {}, + #[serde(rename = "pane.output_matched")] + PaneOutputMatched { + pane_id: String, + source: ReadSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + lines: Option, + r#match: OutputMatch, + #[serde(default = "super::common::default_true")] + strip_ansi: bool, + }, + #[serde(rename = "pane.agent_status_changed")] + PaneAgentStatusChanged { + pane_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + agent_status: Option, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EventsWaitParams { + pub match_event: EventMatch, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneWaitForOutputParams { + pub pane_id: String, + pub source: ReadSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lines: Option, + pub r#match: OutputMatch, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option, + #[serde(default = "super::common::default_true")] + pub strip_ansi: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum OutputMatch { + Substring { value: String }, + Regex { value: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum EventMatch { + WorkspaceCreated { + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option, + }, + WorkspaceUpdated { + workspace_id: String, + }, + WorkspaceClosed { + workspace_id: String, + }, + WorkspaceRenamed { + workspace_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + label: Option, + }, + WorkspaceFocused { + workspace_id: String, + }, + TabCreated { + #[serde(default, skip_serializing_if = "Option::is_none")] + tab_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option, + }, + TabClosed { + tab_id: String, + }, + TabRenamed { + tab_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + label: Option, + }, + TabFocused { + tab_id: String, + }, + PaneCreated { + #[serde(default, skip_serializing_if = "Option::is_none")] + pane_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option, + }, + PaneClosed { + pane_id: String, + }, + PaneFocused { + pane_id: String, + }, + PaneMoved { + pane_id: String, + }, + PaneOutputChanged { + pane_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + min_revision: Option, + }, + PaneExited { + pane_id: String, + }, + PaneAgentDetected { + pane_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + agent: Option, + }, + PaneAgentStatusChanged { + pane_id: String, + agent_status: AgentStatus, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EventKind { + WorkspaceCreated, + WorkspaceUpdated, + WorkspaceClosed, + WorkspaceRenamed, + WorkspaceFocused, + WorktreeCreated, + WorktreeOpened, + WorktreeRemoved, + TabCreated, + TabClosed, + TabRenamed, + TabFocused, + PaneCreated, + PaneClosed, + PaneFocused, + PaneMoved, + PaneOutputChanged, + PaneExited, + PaneAgentDetected, + PaneAgentStatusChanged, +} + +impl EventKind { + pub fn dot_name(self) -> &'static str { + match self { + EventKind::WorkspaceCreated => "workspace.created", + EventKind::WorkspaceUpdated => "workspace.updated", + EventKind::WorkspaceClosed => "workspace.closed", + EventKind::WorkspaceRenamed => "workspace.renamed", + EventKind::WorkspaceFocused => "workspace.focused", + EventKind::WorktreeCreated => "worktree.created", + EventKind::WorktreeOpened => "worktree.opened", + EventKind::WorktreeRemoved => "worktree.removed", + EventKind::TabCreated => "tab.created", + EventKind::TabClosed => "tab.closed", + EventKind::TabRenamed => "tab.renamed", + EventKind::TabFocused => "tab.focused", + EventKind::PaneCreated => "pane.created", + EventKind::PaneClosed => "pane.closed", + EventKind::PaneFocused => "pane.focused", + EventKind::PaneMoved => "pane.moved", + EventKind::PaneOutputChanged => "pane.output_changed", + EventKind::PaneExited => "pane.exited", + EventKind::PaneAgentDetected => "pane.agent_detected", + EventKind::PaneAgentStatusChanged => "pane.agent_status_changed", + } + } +} + +#[cfg(test)] +pub const KNOWN_EVENT_KINDS: &[EventKind] = &[ + EventKind::WorkspaceCreated, + EventKind::WorkspaceUpdated, + EventKind::WorkspaceClosed, + EventKind::WorkspaceRenamed, + EventKind::WorkspaceFocused, + EventKind::WorktreeCreated, + EventKind::WorktreeOpened, + EventKind::WorktreeRemoved, + EventKind::TabCreated, + EventKind::TabClosed, + EventKind::TabRenamed, + EventKind::TabFocused, + EventKind::PaneCreated, + EventKind::PaneClosed, + EventKind::PaneFocused, + EventKind::PaneMoved, + EventKind::PaneOutputChanged, + EventKind::PaneExited, + EventKind::PaneAgentDetected, + EventKind::PaneAgentStatusChanged, +]; + +pub const PLUGIN_HOOK_EVENT_KINDS: &[EventKind] = &[ + EventKind::WorkspaceCreated, + EventKind::WorkspaceUpdated, + EventKind::WorkspaceClosed, + EventKind::WorkspaceRenamed, + EventKind::WorkspaceFocused, + EventKind::WorktreeCreated, + EventKind::WorktreeOpened, + EventKind::WorktreeRemoved, + EventKind::TabCreated, + EventKind::TabClosed, + EventKind::TabRenamed, + EventKind::TabFocused, + EventKind::PaneCreated, + EventKind::PaneClosed, + EventKind::PaneFocused, + EventKind::PaneMoved, + EventKind::PaneExited, + EventKind::PaneAgentDetected, + EventKind::PaneAgentStatusChanged, +]; + +#[cfg(test)] +pub fn known_event_names() -> Vec<&'static str> { + KNOWN_EVENT_KINDS + .iter() + .copied() + .map(EventKind::dot_name) + .collect() +} + +/// Event names that manifest `[[events]] on` hooks can reference. This is +/// intentionally narrower than `EventKind` until high-volume output-change hook +/// semantics are implemented. +pub fn plugin_hook_event_names() -> Vec<&'static str> { + PLUGIN_HOOK_EVENT_KINDS + .iter() + .copied() + .map(EventKind::dot_name) + .collect() +} + +#[cfg(test)] +mod known_event_name_tests { + use super::*; + + #[test] + fn known_event_names_stay_in_sync_with_event_kind() { + let mut from_kind = KNOWN_EVENT_KINDS + .iter() + .map(|kind| kind.dot_name()) + .collect::>(); + from_kind.sort_unstable(); + let mut known = known_event_names(); + known.sort_unstable(); + assert_eq!( + from_kind, known, + "known_event_names() out of sync with EventKind" + ); + } + + #[test] + fn plugin_hook_event_names_exclude_unemitted_output_change() { + let names = plugin_hook_event_names(); + assert!(!names.contains(&"pane.output_changed")); + assert!(names.contains(&"pane.moved")); + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EventEnvelope { + pub event: EventKind, + pub data: EventData, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SubscriptionEventKind { + #[serde(rename = "pane.output_matched")] + PaneOutputMatched, + #[serde(rename = "pane.agent_status_changed")] + PaneAgentStatusChanged, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SubscriptionEventEnvelope { + pub event: SubscriptionEventKind, + pub data: SubscriptionEventData, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum SubscriptionEventData { + PaneOutputMatched(PaneOutputMatchedEvent), + PaneAgentStatusChanged(PaneAgentStatusChangedEvent), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneOutputMatchedEvent { + pub pane_id: String, + pub matched_line: String, + pub read: PaneReadResult, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneAgentStatusChangedEvent { + pub pane_id: String, + pub workspace_id: String, + pub agent_status: AgentStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom_status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_agent: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub state_labels: HashMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum EventData { + WorkspaceCreated { + workspace: WorkspaceInfo, + }, + WorkspaceUpdated { + workspace: WorkspaceInfo, + }, + WorkspaceClosed { + workspace_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace: Option, + }, + WorkspaceRenamed { + workspace_id: String, + label: String, + }, + WorkspaceFocused { + workspace_id: String, + }, + WorktreeCreated { + workspace: WorkspaceInfo, + worktree: WorktreeInfo, + }, + WorktreeOpened { + workspace: WorkspaceInfo, + worktree: WorktreeInfo, + already_open: bool, + }, + WorktreeRemoved { + workspace_id: String, + worktree: WorktreeInfo, + forced: bool, + }, + TabCreated { + tab: TabInfo, + }, + TabClosed { + tab_id: String, + workspace_id: String, + }, + TabRenamed { + tab_id: String, + workspace_id: String, + label: String, + }, + TabFocused { + tab_id: String, + workspace_id: String, + }, + PaneCreated { + pane: PaneInfo, + }, + PaneClosed { + pane_id: String, + workspace_id: String, + }, + PaneFocused { + pane_id: String, + workspace_id: String, + }, + PaneMoved { + previous_pane_id: String, + previous_workspace_id: String, + previous_tab_id: String, + pane: Box, + #[serde(default, skip_serializing_if = "Option::is_none")] + created_workspace: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + created_tab: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + closed_workspace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + closed_tab_id: Option, + }, + PaneOutputChanged { + pane_id: String, + workspace_id: String, + revision: u64, + }, + PaneExited { + pane_id: String, + workspace_id: String, + }, + PaneAgentDetected { + pane_id: String, + workspace_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + agent: Option, + }, + PaneAgentStatusChanged { + pane_id: String, + workspace_id: String, + agent_status: AgentStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + agent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + display_agent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + custom_status: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + state_labels: HashMap, + }, +} -- cgit v1.2.3