aboutsummaryrefslogtreecommitdiff
path: root/src/api/schema
diff options
context:
space:
mode:
Diffstat (limited to 'src/api/schema')
-rw-r--r--src/api/schema/agents.rs87
-rw-r--r--src/api/schema/common.rs128
-rw-r--r--src/api/schema/events.rs474
-rw-r--r--src/api/schema/integrations.rs38
-rw-r--r--src/api/schema/panes.rs110
-rw-r--r--src/api/schema/plugins.rs343
-rw-r--r--src/api/schema/response.rs248
-rw-r--r--src/api/schema/server.rs19
-rw-r--r--src/api/schema/tabs.rs42
-rw-r--r--src/api/schema/tests.rs975
-rw-r--r--src/api/schema/workspaces.rs46
-rw-r--r--src/api/schema/worktrees.rs74
12 files changed, 2576 insertions, 8 deletions
diff --git a/src/api/schema/agents.rs b/src/api/schema/agents.rs
new file mode 100644
index 00000000..644211d4
--- /dev/null
+++ b/src/api/schema/agents.rs
@@ -0,0 +1,87 @@
+use std::collections::HashMap;
+
+use serde::{Deserialize, Serialize};
+
+use super::common::{AgentStatus, ReadFormat, ReadSource, SplitDirection};
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AgentReadParams {
+ pub target: String,
+ pub source: ReadSource,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub lines: Option<u32>,
+ #[serde(default)]
+ pub format: ReadFormat,
+ #[serde(default = "super::common::default_true")]
+ pub strip_ansi: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AgentSendParams {
+ pub target: String,
+ pub text: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AgentRenameParams {
+ pub target: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub name: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AgentStartParams {
+ pub name: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub cwd: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub workspace_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub tab_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub split: Option<SplitDirection>,
+ #[serde(default)]
+ pub focus: bool,
+ pub argv: Vec<String>,
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
+ pub env: HashMap<String, String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AgentInfo {
+ pub terminal_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub name: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub agent: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub title: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub display_agent: Option<String>,
+ pub agent_status: AgentStatus,
+ #[serde(default, skip_serializing_if = "super::is_false")]
+ pub screen_detection_skipped: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub custom_status: Option<String>,
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
+ pub state_labels: HashMap<String, String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub agent_session: Option<AgentSessionInfo>,
+ pub workspace_id: String,
+ pub tab_id: String,
+ pub pane_id: String,
+ pub focused: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub cwd: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub foreground_cwd: Option<String>,
+ pub revision: u64,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AgentSessionInfo {
+ pub source: String,
+ pub agent: String,
+ pub kind: crate::agent_resume::AgentSessionRefKind,
+ pub value: String,
+}
diff --git a/src/api/schema/common.rs b/src/api/schema/common.rs
new file mode 100644
index 00000000..cb423683
--- /dev/null
+++ b/src/api/schema/common.rs
@@ -0,0 +1,128 @@
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub struct EmptyParams {}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct WorkspaceTarget {
+ pub workspace_id: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneTarget {
+ pub pane_id: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct TabTarget {
+ pub tab_id: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AgentTarget {
+ pub target: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ClientWindowTitleSetParams {
+ pub title: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum SplitDirection {
+ Right,
+ Down,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ReadSource {
+ Visible,
+ Recent,
+ RecentUnwrapped,
+ Detection,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+#[serde(rename_all = "snake_case")]
+pub enum ReadFormat {
+ #[default]
+ Text,
+ Ansi,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct NotificationShowParams {
+ pub title: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub body: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub position: Option<crate::config::ToastHerdrPosition>,
+ #[serde(default, skip_serializing_if = "NotificationShowSound::is_none")]
+ pub sound: NotificationShowSound,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+#[serde(rename_all = "snake_case")]
+pub enum NotificationShowSound {
+ #[default]
+ None,
+ Done,
+ Request,
+}
+
+impl NotificationShowSound {
+ pub fn is_none(&self) -> bool {
+ matches!(self, Self::None)
+ }
+
+ pub fn to_sound(self) -> Option<crate::sound::Sound> {
+ match self {
+ Self::None => None,
+ Self::Done => Some(crate::sound::Sound::Done),
+ Self::Request => Some(crate::sound::Sound::Request),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum NotificationShowReason {
+ Shown,
+ Disabled,
+ RateLimited,
+ NoForegroundClient,
+ Busy,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ClientWindowTitleReason {
+ Set,
+ Cleared,
+ NoForegroundClient,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum PaneAgentState {
+ Idle,
+ Working,
+ Blocked,
+ Unknown,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AgentStatus {
+ Idle,
+ Working,
+ Blocked,
+ Done,
+ Unknown,
+}
+
+pub(crate) fn default_true() -> bool {
+ true
+}
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<Subscription>,
+}
+
+#[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<u32>,
+ 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<AgentStatus>,
+ },
+}
+
+#[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<u64>,
+}
+
+#[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<u32>,
+ pub r#match: OutputMatch,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub timeout_ms: Option<u64>,
+ #[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<String>,
+ },
+ WorkspaceUpdated {
+ workspace_id: String,
+ },
+ WorkspaceClosed {
+ workspace_id: String,
+ },
+ WorkspaceRenamed {
+ workspace_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ label: Option<String>,
+ },
+ WorkspaceFocused {
+ workspace_id: String,
+ },
+ TabCreated {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ tab_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ workspace_id: Option<String>,
+ },
+ TabClosed {
+ tab_id: String,
+ },
+ TabRenamed {
+ tab_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ label: Option<String>,
+ },
+ TabFocused {
+ tab_id: String,
+ },
+ PaneCreated {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pane_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ workspace_id: Option<String>,
+ },
+ 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<u64>,
+ },
+ PaneExited {
+ pane_id: String,
+ },
+ PaneAgentDetected {
+ pane_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ agent: Option<String>,
+ },
+ 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::<Vec<_>>();
+ 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<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub custom_status: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub title: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub display_agent: Option<String>,
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
+ pub state_labels: HashMap<String, String>,
+}
+
+#[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<WorkspaceInfo>,
+ },
+ 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<PaneInfo>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ created_workspace: Option<WorkspaceInfo>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ created_tab: Option<TabInfo>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ closed_workspace_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ closed_tab_id: Option<String>,
+ },
+ 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<String>,
+ },
+ PaneAgentStatusChanged {
+ pane_id: String,
+ workspace_id: String,
+ agent_status: AgentStatus,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ agent: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ title: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ display_agent: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ custom_status: Option<String>,
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
+ state_labels: HashMap<String, String>,
+ },
+}
diff --git a/src/api/schema/integrations.rs b/src/api/schema/integrations.rs
new file mode 100644
index 00000000..49b27f96
--- /dev/null
+++ b/src/api/schema/integrations.rs
@@ -0,0 +1,38 @@
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct IntegrationInstallParams {
+ pub target: IntegrationTarget,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct IntegrationUninstallParams {
+ pub target: IntegrationTarget,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum IntegrationTarget {
+ Pi,
+ Omp,
+ Claude,
+ Codex,
+ Copilot,
+ Droid,
+ Kimi,
+ Opencode,
+ Kilo,
+ Hermes,
+ Qodercli,
+ Cursor,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct IntegrationInstallResult {
+ pub messages: Vec<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct IntegrationUninstallResult {
+ pub messages: Vec<String>,
+}
diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs
index de330993..638829b0 100644
--- a/src/api/schema/panes.rs
+++ b/src/api/schema/panes.rs
@@ -2,7 +2,8 @@ use std::collections::HashMap;
use serde::{Deserialize, Serialize};
-use super::{AgentSessionInfo, AgentStatus, PaneAgentState, ReadFormat, ReadSource};
+use super::agents::AgentSessionInfo;
+use super::common::{AgentStatus, PaneAgentState, ReadFormat, ReadSource, SplitDirection};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PaneSplitParams {
@@ -17,13 +18,8 @@ pub struct PaneSplitParams {
pub cwd: Option<String>,
#[serde(default)]
pub focus: bool,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum SplitDirection {
- Right,
- Down,
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
+ pub env: HashMap<String, String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -103,6 +99,71 @@ pub struct PaneLayoutParams {
pub pane_id: Option<String>,
}
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub struct PaneProcessInfoParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub pane_id: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub struct LayoutExportParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub tab_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub pane_id: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct LayoutApplyParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub workspace_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub tab_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub tab_label: Option<String>,
+ #[serde(default)]
+ pub focus: bool,
+ pub root: LayoutNode,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct LayoutDescription {
+ pub workspace_id: String,
+ pub tab_id: String,
+ pub zoomed: bool,
+ pub focused_pane_id: String,
+ pub root: LayoutNode,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum LayoutNode {
+ Pane {
+ #[serde(flatten)]
+ pane: LayoutPane,
+ },
+ Split {
+ direction: SplitDirection,
+ ratio: f32,
+ first: Box<LayoutNode>,
+ second: Box<LayoutNode>,
+ },
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
+pub struct LayoutPane {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub pane_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub label: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub cwd: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub command: Option<Vec<String>>,
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
+ pub env: HashMap<String, String>,
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneNeighborParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -138,6 +199,12 @@ pub struct PaneListParams {
pub workspace_id: Option<String>,
}
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub struct PaneCurrentParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub caller_pane_id: Option<String>,
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneRenameParams {
pub pane_id: String,
@@ -286,6 +353,33 @@ pub struct PaneInfo {
pub revision: u64,
}
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneProcessInfo {
+ pub pane_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub shell_pid: Option<u32>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub foreground_process_group_id: Option<u32>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub tty: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub foreground_processes: Vec<PaneProcessInfoProcess>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneProcessInfoProcess {
+ pub pid: u32,
+ pub name: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub argv0: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub argv: Option<Vec<String>>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub cmdline: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub cwd: Option<String>,
+}
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PaneSwapResult {
pub changed: bool,
diff --git a/src/api/schema/plugins.rs b/src/api/schema/plugins.rs
new file mode 100644
index 00000000..535733ec
--- /dev/null
+++ b/src/api/schema/plugins.rs
@@ -0,0 +1,343 @@
+use std::collections::HashMap;
+
+use serde::{Deserialize, Serialize};
+
+use super::common::AgentStatus;
+use super::common::SplitDirection;
+use super::panes::PaneInfo;
+use super::workspaces::WorkspaceWorktreeInfo;
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PluginLinkParams {
+ pub path: String,
+ #[serde(default = "super::common::default_true")]
+ pub enabled: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub source: Option<PluginSourceInfo>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub struct PluginListParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub plugin_id: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PluginUnlinkParams {
+ pub plugin_id: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PluginSetEnabledParams {
+ pub plugin_id: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct InstalledPluginInfo {
+ pub plugin_id: String,
+ pub name: String,
+ pub version: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub description: Option<String>,
+ pub manifest_path: String,
+ pub plugin_root: String,
+ pub enabled: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub platforms: Option<Vec<PluginPlatform>>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub build: Vec<PluginManifestBuild>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub actions: Vec<PluginManifestAction>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub events: Vec<PluginManifestEventHook>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub panes: Vec<PluginManifestPane>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub link_handlers: Vec<PluginManifestLinkHandler>,
+ #[serde(default)]
+ pub source: PluginSourceInfo,
+ /// Warnings collected at link time or on registry load (e.g. unknown event names,
+ /// missing manifest file). Non-fatal — the entry is kept and surfaced by plugin.list.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub warnings: Vec<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PluginSourceInfo {
+ #[serde(default)]
+ pub kind: PluginSourceKind,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub owner: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub repo: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub subdir: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub requested_ref: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub resolved_commit: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub managed_path: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub installed_unix_ms: Option<u64>,
+}
+
+impl Default for PluginSourceInfo {
+ fn default() -> Self {
+ Self {
+ kind: PluginSourceKind::Local,
+ owner: None,
+ repo: None,
+ subdir: None,
+ requested_ref: None,
+ resolved_commit: None,
+ managed_path: None,
+ installed_unix_ms: None,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+#[serde(rename_all = "snake_case")]
+pub enum PluginSourceKind {
+ #[default]
+ Local,
+ Github,
+}
+
+pub(crate) fn plugin_managed_path_component(value: &str) -> String {
+ value
+ .chars()
+ .map(|ch| {
+ if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
+ ch
+ } else {
+ '_'
+ }
+ })
+ .collect()
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PluginManifestBuild {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub platforms: Option<Vec<PluginPlatform>>,
+ pub command: Vec<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PluginManifestAction {
+ pub id: String,
+ pub title: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub description: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub contexts: Vec<PluginActionContext>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub platforms: Option<Vec<PluginPlatform>>,
+ pub command: Vec<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PluginManifestEventHook {
+ pub on: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub platforms: Option<Vec<PluginPlatform>>,
+ pub command: Vec<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PluginManifestPane {
+ pub id: String,
+ pub title: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub description: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub platforms: Option<Vec<PluginPlatform>>,
+ #[serde(default)]
+ pub placement: PluginPanePlacement,
+ pub command: Vec<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PluginManifestLinkHandler {
+ pub id: String,
+ pub title: String,
+ pub pattern: String,
+ pub action: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub platforms: Option<Vec<PluginPlatform>>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub struct PluginActionListParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub plugin_id: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub struct PluginLogListParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub plugin_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub limit: Option<usize>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PluginActionInvokeParams {
+ pub action_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub plugin_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub context: Option<PluginInvocationContext>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PluginCommandLogInfo {
+ pub log_id: String,
+ pub plugin_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub action_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub event: Option<String>,
+ pub command: Vec<String>,
+ pub status: PluginCommandStatus,
+ pub started_unix_ms: u64,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub finished_unix_ms: Option<u64>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub exit_code: Option<i32>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub stdout: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub stderr: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub error: Option<String>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum PluginCommandStatus {
+ Running,
+ Succeeded,
+ Failed,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum PluginPlatform {
+ Linux,
+ Macos,
+ Windows,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum PluginActionContext {
+ Global,
+ Workspace,
+ Tab,
+ Pane,
+ Selection,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PluginInvocationContext {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub workspace_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub workspace_label: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub workspace_cwd: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub worktree: Option<WorkspaceWorktreeInfo>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub tab_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub tab_label: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub focused_pane_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub focused_pane_cwd: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub focused_pane_agent: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub focused_pane_status: Option<AgentStatus>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub selected_text: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub invocation_source: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub correlation_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub clicked_url: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub link_handler_id: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PluginActionInfo {
+ pub plugin_id: String,
+ pub action_id: String,
+ pub title: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub description: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub contexts: Vec<PluginActionContext>,
+ pub command: Vec<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub platforms: Option<Vec<PluginPlatform>>,
+}
+
+impl PluginActionInfo {
+ pub fn qualified_id(&self) -> String {
+ format!("{}.{}", self.plugin_id, self.action_id)
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PluginPaneOpenParams {
+ pub plugin_id: String,
+ pub entrypoint: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub placement: Option<PluginPanePlacement>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub workspace_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub target_pane_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub direction: Option<SplitDirection>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub cwd: Option<String>,
+ #[serde(default)]
+ pub focus: bool,
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
+ pub env: HashMap<String, String>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+#[serde(rename_all = "snake_case")]
+pub enum PluginPanePlacement {
+ #[default]
+ Overlay,
+ Split,
+ Tab,
+ Zoomed,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PluginPaneFocusParams {
+ pub pane_id: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PluginPaneCloseParams {
+ pub pane_id: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PluginPaneInfo {
+ pub plugin_id: String,
+ pub entrypoint: String,
+ pub pane: PaneInfo,
+}
diff --git a/src/api/schema/response.rs b/src/api/schema/response.rs
new file mode 100644
index 00000000..51ea0d36
--- /dev/null
+++ b/src/api/schema/response.rs
@@ -0,0 +1,248 @@
+use serde::{Deserialize, Serialize};
+
+use super::agents::AgentInfo;
+use super::common::{ClientWindowTitleReason, NotificationShowReason};
+use super::events::EventEnvelope;
+use super::integrations::{
+ IntegrationInstallResult, IntegrationTarget, IntegrationUninstallResult,
+};
+use super::panes::{
+ LayoutDescription, PaneEdgesResult, PaneFocusDirectionResult, PaneInfo, PaneLayoutSnapshot,
+ PaneMoveResult, PaneNeighborResult, PaneProcessInfo, PaneReadResult, PaneResizeResult,
+ PaneSwapResult, PaneZoomResult,
+};
+use super::plugins::{
+ InstalledPluginInfo, PluginActionInfo, PluginCommandLogInfo, PluginInvocationContext,
+ PluginPaneInfo,
+};
+use super::server::ServerCapabilities;
+use super::tabs::TabInfo;
+use super::workspaces::WorkspaceInfo;
+use super::worktrees::{WorktreeInfo, WorktreeSourceInfo};
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct SuccessResponse {
+ pub id: String,
+ pub result: ResponseResult,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ErrorResponse {
+ pub id: String,
+ pub error: ErrorBody,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ErrorBody {
+ pub code: String,
+ pub message: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum ResponseResult {
+ Pong {
+ version: String,
+ protocol: u32,
+ #[serde(default)]
+ capabilities: Option<ServerCapabilities>,
+ },
+ WorkspaceInfo {
+ workspace: WorkspaceInfo,
+ },
+ WorkspaceCreated {
+ workspace: WorkspaceInfo,
+ tab: TabInfo,
+ root_pane: PaneInfo,
+ },
+ WorkspaceList {
+ workspaces: Vec<WorkspaceInfo>,
+ },
+ WorktreeList {
+ source: WorktreeSourceInfo,
+ worktrees: Vec<WorktreeInfo>,
+ },
+ WorktreeCreated {
+ workspace: WorkspaceInfo,
+ tab: TabInfo,
+ root_pane: PaneInfo,
+ worktree: WorktreeInfo,
+ },
+ WorktreeOpened {
+ workspace: WorkspaceInfo,
+ tab: TabInfo,
+ root_pane: PaneInfo,
+ worktree: WorktreeInfo,
+ already_open: bool,
+ },
+ WorktreeRemoved {
+ workspace_id: String,
+ path: String,
+ forced: bool,
+ },
+ TabInfo {
+ tab: TabInfo,
+ },
+ TabCreated {
+ tab: TabInfo,
+ root_pane: PaneInfo,
+ },
+ TabList {
+ tabs: Vec<TabInfo>,
+ },
+ AgentInfo {
+ agent: AgentInfo,
+ },
+ AgentStarted {
+ agent: AgentInfo,
+ argv: Vec<String>,
+ },
+ AgentList {
+ agents: Vec<AgentInfo>,
+ },
+ PaneInfo {
+ pane: PaneInfo,
+ },
+ PaneList {
+ panes: Vec<PaneInfo>,
+ },
+ PaneCurrent {
+ pane: PaneInfo,
+ },
+ PaneSwap {
+ swap: PaneSwapResult,
+ },
+ PaneMove {
+ move_result: PaneMoveResult,
+ },
+ PaneZoom {
+ zoom: PaneZoomResult,
+ },
+ PaneLayout {
+ layout: PaneLayoutSnapshot,
+ },
+ PaneProcessInfo {
+ process_info: PaneProcessInfo,
+ },
+ LayoutExport {
+ layout: LayoutDescription,
+ },
+ LayoutApply {
+ layout: LayoutDescription,
+ },
+ PaneNeighbor {
+ neighbor: PaneNeighborResult,
+ },
+ PaneEdges {
+ edges: PaneEdgesResult,
+ },
+ PaneFocusDirection {
+ focus: PaneFocusDirectionResult,
+ },
+ PaneResize {
+ resize: PaneResizeResult,
+ },
+ PaneRead {
+ read: PaneReadResult,
+ },
+ AgentExplain {
+ explain: serde_json::Value,
+ },
+ SubscriptionStarted {},
+ WaitMatched {
+ event: EventEnvelope,
+ },
+ OutputMatched {
+ pane_id: String,
+ revision: u64,
+ matched_line: Option<String>,
+ read: PaneReadResult,
+ },
+ NotificationShow {
+ shown: bool,
+ reason: NotificationShowReason,
+ },
+ ClientWindowTitle {
+ changed: bool,
+ reason: ClientWindowTitleReason,
+ },
+ IntegrationInstall {
+ target: IntegrationTarget,
+ details: IntegrationInstallResult,
+ },
+ IntegrationUninstall {
+ target: IntegrationTarget,
+ details: IntegrationUninstallResult,
+ },
+ AgentManifestReload {
+ manifests: Vec<AgentManifestInfo>,
+ },
+ AgentManifestStatus {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ last_check_unix: Option<u64>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ last_result: Option<String>,
+ manifests: Vec<AgentManifestInfo>,
+ },
+ PluginLinked {
+ plugin: InstalledPluginInfo,
+ },
+ PluginList {
+ plugins: Vec<InstalledPluginInfo>,
+ },
+ PluginUnlinked {
+ plugin_id: String,
+ removed: bool,
+ },
+ PluginEnabled {
+ plugin: InstalledPluginInfo,
+ },
+ PluginDisabled {
+ plugin: InstalledPluginInfo,
+ },
+ PluginActionList {
+ actions: Vec<PluginActionInfo>,
+ },
+ PluginActionInvoked {
+ action: PluginActionInfo,
+ context: PluginInvocationContext,
+ log: PluginCommandLogInfo,
+ },
+ PluginLogList {
+ logs: Vec<PluginCommandLogInfo>,
+ },
+ PluginPaneOpened {
+ plugin_pane: PluginPaneInfo,
+ },
+ PluginPaneFocused {
+ plugin_pane: PluginPaneInfo,
+ },
+ PluginPaneClosed {
+ pane_id: String,
+ },
+ ConfigReload {
+ status: crate::config::ConfigReloadStatus,
+ diagnostics: Vec<String>,
+ },
+ Ok {},
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AgentManifestInfo {
+ pub agent: String,
+ pub source: String,
+ pub source_kind: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub active_version: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub cached_remote_version: Option<String>,
+ pub local_override_shadowing_remote: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub remote_update_result: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub remote_update_error: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub remote_last_checked_unix: Option<u64>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub warning: Option<String>,
+}
diff --git a/src/api/schema/server.rs b/src/api/schema/server.rs
new file mode 100644
index 00000000..59ce8686
--- /dev/null
+++ b/src/api/schema/server.rs
@@ -0,0 +1,19 @@
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub struct PingParams {}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ServerLiveHandoffParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub import_exe: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub expected_protocol: Option<u32>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub expected_version: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ServerCapabilities {
+ pub live_handoff: bool,
+}
diff --git a/src/api/schema/tabs.rs b/src/api/schema/tabs.rs
new file mode 100644
index 00000000..4be94c3b
--- /dev/null
+++ b/src/api/schema/tabs.rs
@@ -0,0 +1,42 @@
+use std::collections::HashMap;
+
+use serde::{Deserialize, Serialize};
+
+use super::common::AgentStatus;
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct TabCreateParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub workspace_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub cwd: Option<String>,
+ #[serde(default)]
+ pub focus: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub label: Option<String>,
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
+ pub env: HashMap<String, String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub struct TabListParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub workspace_id: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct TabRenameParams {
+ pub tab_id: String,
+ pub label: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct TabInfo {
+ pub tab_id: String,
+ pub workspace_id: String,
+ pub number: usize,
+ pub label: String,
+ pub focused: bool,
+ pub pane_count: usize,
+ pub agent_status: AgentStatus,
+}
diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs
new file mode 100644
index 00000000..c1713972
--- /dev/null
+++ b/src/api/schema/tests.rs
@@ -0,0 +1,975 @@
+use std::collections::HashMap;
+
+use super::*;
+
+#[test]
+fn request_round_trips_for_pane_read() {
+ let request = Request {
+ id: "req_1".into(),
+ method: Method::PaneRead(PaneReadParams {
+ pane_id: "p_1".into(),
+ source: ReadSource::Recent,
+ lines: Some(80),
+ format: ReadFormat::Text,
+ strip_ansi: true,
+ }),
+ };
+
+ let json = serde_json::to_string(&request).unwrap();
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, request);
+}
+
+#[test]
+fn request_round_trips_for_pane_report_agent() {
+ let request = Request {
+ id: "req_hook".into(),
+ method: Method::PaneReportAgent(PaneReportAgentParams {
+ pane_id: "1-1".into(),
+ source: "herdr:pi".into(),
+ agent: "pi".into(),
+ state: PaneAgentState::Working,
+ message: Some("thinking".into()),
+ custom_status: Some("indexing".into()),
+ seq: Some(42),
+ agent_session_id: Some("pi-session".into()),
+ agent_session_path: Some("/tmp/pi-session.jsonl".into()),
+ }),
+ };
+
+ let json = serde_json::to_string(&request).unwrap();
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, request);
+}
+
+#[test]
+fn request_round_trips_for_pane_report_agent_session() {
+ let request = Request {
+ id: "req_session".into(),
+ method: Method::PaneReportAgentSession(PaneReportAgentSessionParams {
+ pane_id: "1-1".into(),
+ source: "herdr:claude".into(),
+ agent: "claude".into(),
+ seq: Some(42),
+ agent_session_id: Some("claude-session".into()),
+ agent_session_path: None,
+ }),
+ };
+
+ let json = serde_json::to_string(&request).unwrap();
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, request);
+}
+
+#[test]
+fn request_round_trips_for_pane_report_metadata() {
+ let request = Request {
+ id: "req_metadata".into(),
+ method: Method::PaneReportMetadata(PaneReportMetadataParams {
+ pane_id: "1-1".into(),
+ source: "user:claude-title".into(),
+ agent: Some("claude".into()),
+ applies_to_source: Some("herdr:claude".into()),
+ title: Some("Refactor auth".into()),
+ display_agent: Some("Claude auth".into()),
+ custom_status: Some("refactor auth".into()),
+ state_labels: HashMap::from([("working".into(), "deep in the mines".into())]),
+ clear_title: false,
+ clear_display_agent: false,
+ clear_custom_status: false,
+ clear_state_labels: false,
+ seq: Some(42),
+ ttl_ms: Some(3_600_000),
+ }),
+ };
+
+ let json = serde_json::to_string(&request).unwrap();
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, request);
+}
+
+#[test]
+fn request_round_trips_for_pane_clear_agent_authority() {
+ let request = Request {
+ id: "req_clear".into(),
+ method: Method::PaneClearAgentAuthority(PaneClearAgentAuthorityParams {
+ pane_id: "1-1".into(),
+ source: Some("herdr:pi".into()),
+ seq: Some(42),
+ }),
+ };
+
+ let json = serde_json::to_string(&request).unwrap();
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, request);
+}
+
+#[test]
+fn request_round_trips_for_pane_release_agent() {
+ let request = Request {
+ id: "req_release".into(),
+ method: Method::PaneReleaseAgent(PaneReleaseAgentParams {
+ pane_id: "1-1".into(),
+ source: "herdr:pi".into(),
+ agent: "pi".into(),
+ seq: Some(42),
+ }),
+ };
+
+ let json = serde_json::to_string(&request).unwrap();
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, request);
+}
+
+#[test]
+fn request_uses_dot_method_names() {
+ let request = Request {
+ id: "req_1".into(),
+ method: Method::WorkspaceCreate(WorkspaceCreateParams {
+ cwd: Some("/tmp".into()),
+ focus: true,
+ label: Some("api".into()),
+ env: Default::default(),
+ }),
+ };
+
+ let json = serde_json::to_value(&request).unwrap();
+ assert_eq!(json["method"], "workspace.create");
+}
+
+#[test]
+fn request_round_trips_for_server_stop() {
+ let request = Request {
+ id: "req_stop".into(),
+ method: Method::ServerStop(EmptyParams::default()),
+ };
+
+ let json = serde_json::to_value(&request).unwrap();
+ assert_eq!(json["method"], "server.stop");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, request);
+}
+
+#[test]
+fn request_round_trips_for_server_reload_config() {
+ let request = Request {
+ id: "req_reload".into(),
+ method: Method::ServerReloadConfig(EmptyParams::default()),
+ };
+
+ let json = serde_json::to_value(&request).unwrap();
+ assert_eq!(json["method"], "server.reload_config");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, request);
+}
+
+#[test]
+fn request_round_trips_for_server_reload_agent_manifests() {
+ let request = Request {
+ id: "req_reload_agent_manifests".into(),
+ method: Method::ServerReloadAgentManifests(EmptyParams::default()),
+ };
+
+ let json = serde_json::to_value(&request).unwrap();
+ assert_eq!(json["method"], "server.reload_agent_manifests");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, request);
+}
+
+#[test]
+fn request_round_trips_for_server_agent_manifests() {
+ let request = Request {
+ id: "req_agent_manifests".into(),
+ method: Method::ServerAgentManifests(EmptyParams::default()),
+ };
+
+ let json = serde_json::to_value(&request).unwrap();
+ assert_eq!(json["method"], "server.agent_manifests");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, request);
+}
+
+#[test]
+fn request_round_trips_for_agent_explain() {
+ let request = Request {
+ id: "req_agent_explain".into(),
+ method: Method::AgentExplain(AgentTarget {
+ target: "agent-1".into(),
+ }),
+ };
+
+ let json = serde_json::to_value(&request).unwrap();
+ assert_eq!(json["method"], "agent.explain");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, request);
+}
+
+#[test]
+fn notification_show_request_parses() {
+ let json = r#"{"id":"req_1","method":"notification.show","params":{"title":"build failed","body":"api workspace","position":"top-left","sound":"request"}}"#;
+ let request: Request = serde_json::from_str(json).unwrap();
+ let Method::NotificationShow(params) = request.method else {
+ panic!("wrong method parsed");
+ };
+ assert_eq!(params.title, "build failed");
+ assert_eq!(params.body.as_deref(), Some("api workspace"));
+ assert_eq!(
+ params.position,
+ Some(crate::config::ToastHerdrPosition::TopLeft)
+ );
+ assert_eq!(params.sound, NotificationShowSound::Request);
+}
+
+#[test]
+fn notification_show_sound_defaults_to_none() {
+ let json = r#"{"id":"req_1","method":"notification.show","params":{"title":"build failed"}}"#;
+ let request: Request = serde_json::from_str(json).unwrap();
+ let Method::NotificationShow(params) = request.method else {
+ panic!("wrong method parsed");
+ };
+
+ assert_eq!(params.sound, NotificationShowSound::None);
+}
+
+#[test]
+fn client_window_title_requests_round_trip() {
+ let set = Request {
+ id: "req_title_set".into(),
+ method: Method::ClientWindowTitleSet(ClientWindowTitleSetParams {
+ title: "herdr api".into(),
+ }),
+ };
+ let json = serde_json::to_value(&set).unwrap();
+ assert_eq!(json["method"], "client.window_title.set");
+ assert_eq!(json["params"]["title"], "herdr api");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, set);
+
+ let clear = Request {
+ id: "req_title_clear".into(),
+ method: Method::ClientWindowTitleClear(EmptyParams::default()),
+ };
+ let json = serde_json::to_value(&clear).unwrap();
+ assert_eq!(json["method"], "client.window_title.clear");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, clear);
+}
+
+#[test]
+fn unknown_method_is_rejected() {
+ let json = r#"{"id":"req_1","method":"nope","params":{}}"#;
+ let err = serde_json::from_str::<Request>(json)
+ .unwrap_err()
+ .to_string();
+ assert!(err.contains("unknown variant"));
+}
+
+#[test]
+fn missing_required_params_are_rejected() {
+ let json = r#"{"id":"req_1","method":"pane.send_text","params":{"pane_id":"p_1"}}"#;
+ let err = serde_json::from_str::<Request>(json)
+ .unwrap_err()
+ .to_string();
+ assert!(err.contains("text"));
+}
+
+#[test]
+fn pane_send_input_defaults_to_empty_text_and_keys() {
+ let json = r#"
+ {
+ "id": "req_1",
+ "method": "pane.send_input",
+ "params": {
+ "pane_id": "p_1"
+ }
+ }
+ "#;
+
+ let request: Request = serde_json::from_str(json).unwrap();
+ let Method::PaneSendInput(params) = request.method else {
+ panic!("wrong method parsed");
+ };
+ assert_eq!(params.pane_id, "p_1");
+ assert!(params.text.is_empty());
+ assert!(params.keys.is_empty());
+}
+
+#[test]
+fn pane_wait_for_output_defaults_strip_ansi_to_true() {
+ let json = r#"
+ {
+ "id": "req_1",
+ "method": "pane.wait_for_output",
+ "params": {
+ "pane_id": "p_1",
+ "source": "recent",
+ "match": { "type": "substring", "value": "ready" }
+ }
+ }
+ "#;
+
+ let request: Request = serde_json::from_str(json).unwrap();
+ let Method::PaneWaitForOutput(params) = request.method else {
+ panic!("wrong method parsed");
+ };
+ assert!(params.strip_ansi);
+}
+
+#[test]
+fn pane_read_defaults_to_text_format() {
+ let json = r#"
+ {
+ "id": "req_1",
+ "method": "pane.read",
+ "params": {
+ "pane_id": "p_1",
+ "source": "visible"
+ }
+ }
+ "#;
+
+ let request: Request = serde_json::from_str(json).unwrap();
+ let Method::PaneRead(params) = request.method else {
+ panic!("wrong method parsed");
+ };
+ assert_eq!(params.format, ReadFormat::Text);
+}
+
+#[test]
+fn pane_current_request_round_trips() {
+ let request = Request {
+ id: "req_current".into(),
+ method: Method::PaneCurrent(PaneCurrentParams {
+ caller_pane_id: Some("w1-1".into()),
+ }),
+ };
+
+ let json = serde_json::to_value(&request).unwrap();
+ assert_eq!(json["method"], "pane.current");
+ assert_eq!(json["params"]["caller_pane_id"], "w1-1");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, request);
+}
+
+#[test]
+fn pane_process_info_request_round_trips() {
+ let request = Request {
+ id: "req_process_info".into(),
+ method: Method::PaneProcessInfo(PaneProcessInfoParams {
+ pane_id: Some("w1-1".into()),
+ }),
+ };
+
+ let json = serde_json::to_value(&request).unwrap();
+ assert_eq!(json["method"], "pane.process_info");
+ assert_eq!(json["params"]["pane_id"], "w1-1");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, request);
+}
+
+#[test]
+fn event_envelope_round_trips() {
+ let event = EventEnvelope {
+ event: EventKind::PaneOutputChanged,
+ data: EventData::PaneOutputChanged {
+ pane_id: "p_1".into(),
+ workspace_id: "w_1".into(),
+ revision: 42,
+ },
+ };
+
+ let json = serde_json::to_string(&event).unwrap();
+ let restored: EventEnvelope = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, event);
+}
+
+#[test]
+fn subscribe_request_parses_parameterized_subscriptions() {
+ let json = r#"
+ {
+ "id": "sub_1",
+ "method": "events.subscribe",
+ "params": {
+ "subscriptions": [
+ {
+ "type": "pane.output_matched",
+ "pane_id": "p_1_1",
+ "source": "recent",
+ "lines": 200,
+ "match": { "type": "substring", "value": "auth: received" }
+ },
+ {
+ "type": "pane.agent_status_changed",
+ "pane_id": "p_1_1",
+ "agent_status": "done"
+ }
+ ]
+ }
+ }
+ "#;
+
+ let request: Request = serde_json::from_str(json).unwrap();
+ let Method::EventsSubscribe(params) = request.method else {
+ panic!("wrong method parsed");
+ };
+ assert_eq!(params.subscriptions.len(), 2);
+ assert!(matches!(
+ &params.subscriptions[0],
+ Subscription::PaneOutputMatched {
+ pane_id,
+ source: ReadSource::Recent,
+ lines: Some(200),
+ r#match: OutputMatch::Substring { value },
+ strip_ansi: true,
+ } if pane_id == "p_1_1" && value == "auth: received"
+ ));
+ assert!(matches!(
+ &params.subscriptions[1],
+ Subscription::PaneAgentStatusChanged {
+ pane_id,
+ agent_status: Some(AgentStatus::Done),
+ } if pane_id == "p_1_1"
+ ));
+}
+
+#[test]
+fn subscription_event_envelope_round_trips() {
+ let event = SubscriptionEventEnvelope {
+ event: SubscriptionEventKind::PaneOutputMatched,
+ data: SubscriptionEventData::PaneOutputMatched(PaneOutputMatchedEvent {
+ pane_id: "p_1_1".into(),
+ matched_line: "auth: received".into(),
+ read: PaneReadResult {
+ pane_id: "p_1_1".into(),
+ workspace_id: "w_1".into(),
+ tab_id: "t_1_1".into(),
+ source: ReadSource::Recent,
+ format: ReadFormat::Text,
+ text: "auth: received\n".into(),
+ revision: 0,
+ truncated: false,
+ },
+ }),
+ };
+
+ let json = serde_json::to_string(&event).unwrap();
+ assert!(json.contains("\"event\":\"pane.output_matched\""));
+ let restored: SubscriptionEventEnvelope = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, event);
+}
+
+#[test]
+fn success_response_round_trips() {
+ let response = SuccessResponse {
+ id: "req_1".into(),
+ result: ResponseResult::Pong {
+ version: "0.1.2".into(),
+ protocol: 6,
+ capabilities: Some(ServerCapabilities { live_handoff: true }),
+ },
+ };
+
+ let json = serde_json::to_string(&response).unwrap();
+ let restored: SuccessResponse = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, response);
+}
+
+#[test]
+fn worktree_request_and_response_round_trip() {
+ let request = Request {
+ id: "req_worktree".into(),
+ method: Method::WorktreeCreate(WorktreeCreateParams {
+ workspace_id: Some("1".into()),
+ branch: Some("worktree/api".into()),
+ base: Some("HEAD".into()),
+ focus: true,
+ ..WorktreeCreateParams::default()
+ }),
+ };
+ let json = serde_json::to_string(&request).unwrap();
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, request);
+
+ let response = SuccessResponse {
+ id: "req_worktree".into(),
+ result: ResponseResult::WorktreeCreated {
+ workspace: WorkspaceInfo {
+ workspace_id: "w_1".into(),
+ number: 2,
+ label: "herdr".into(),
+ focused: true,
+ pane_count: 1,
+ tab_count: 1,
+ active_tab_id: "w_1:1".into(),
+ agent_status: AgentStatus::Unknown,
+ worktree: Some(WorkspaceWorktreeInfo {
+ repo_key: "/repo/herdr/.git".into(),
+ repo_name: "herdr".into(),
+ repo_root: "/repo/herdr".into(),
+ checkout_path: "/worktrees/herdr/worktree-api".into(),
+ is_linked_worktree: true,
+ }),
+ },
+ tab: TabInfo {
+ tab_id: "w_1:1".into(),
+ workspace_id: "w_1".into(),
+ number: 1,
+ label: "herdr".into(),
+ focused: true,
+ pane_count: 1,
+ agent_status: AgentStatus::Unknown,
+ },
+ root_pane: PaneInfo {
+ pane_id: "w_1-1".into(),
+ terminal_id: "term_1".into(),
+ workspace_id: "w_1".into(),
+ tab_id: "w_1:1".into(),
+ focused: true,
+ cwd: Some("/worktrees/herdr/worktree-api".into()),
+ foreground_cwd: None,
+ label: None,
+ agent: None,
+ title: None,
+ display_agent: None,
+ agent_status: AgentStatus::Unknown,
+ custom_status: None,
+ state_labels: HashMap::new(),
+ agent_session: None,
+ revision: 0,
+ },
+ worktree: WorktreeInfo {
+ path: "/worktrees/herdr/worktree-api".into(),
+ branch: Some("worktree/api".into()),
+ is_bare: false,
+ is_detached: false,
+ is_prunable: false,
+ is_linked_worktree: true,
+ open_workspace_id: Some("w_1".into()),
+ label: "herdr".into(),
+ },
+ },
+ };
+ let json = serde_json::to_string(&response).unwrap();
+ assert!(json.contains("\"type\":\"worktree_created\""));
+ assert!(json.contains("\"worktree\""));
+ let restored: SuccessResponse = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, response);
+}
+
+#[test]
+fn worktree_lifecycle_events_round_trip() {
+ let subscription = Request {
+ id: "sub_worktrees".into(),
+ method: Method::EventsSubscribe(EventsSubscribeParams {
+ subscriptions: vec![
+ Subscription::WorktreeCreated {},
+ Subscription::WorktreeOpened {},
+ Subscription::WorktreeRemoved {},
+ ],
+ }),
+ };
+ let json = serde_json::to_string(&subscription).unwrap();
+ assert!(json.contains("\"type\":\"worktree.created\""));
+ assert!(json.contains("\"type\":\"worktree.opened\""));
+ assert!(json.contains("\"type\":\"worktree.removed\""));
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, subscription);
+
+ let workspace = WorkspaceInfo {
+ workspace_id: "w_2".into(),
+ number: 2,
+ label: "herdr".into(),
+ focused: true,
+ pane_count: 1,
+ tab_count: 1,
+ active_tab_id: "w_2:1".into(),
+ agent_status: AgentStatus::Unknown,
+ worktree: Some(WorkspaceWorktreeInfo {
+ repo_key: "/repo/herdr/.git".into(),
+ repo_name: "herdr".into(),
+ repo_root: "/repo/herdr".into(),
+ checkout_path: "/worktrees/herdr/worktree-api".into(),
+ is_linked_worktree: true,
+ }),
+ };
+ let worktree = WorktreeInfo {
+ path: "/worktrees/herdr/worktree-api".into(),
+ branch: Some("worktree/api".into()),
+ is_bare: false,
+ is_detached: false,
+ is_prunable: false,
+ is_linked_worktree: true,
+ open_workspace_id: Some("w_2".into()),
+ label: "herdr".into(),
+ };
+
+ for event in [
+ EventEnvelope {
+ event: EventKind::WorktreeCreated,
+ data: EventData::WorktreeCreated {
+ workspace: workspace.clone(),
+ worktree: worktree.clone(),
+ },
+ },
+ EventEnvelope {
+ event: EventKind::WorktreeOpened,
+ data: EventData::WorktreeOpened {
+ workspace: workspace.clone(),
+ worktree: worktree.clone(),
+ already_open: false,
+ },
+ },
+ EventEnvelope {
+ event: EventKind::WorktreeRemoved,
+ data: EventData::WorktreeRemoved {
+ workspace_id: "w_2".into(),
+ worktree: WorktreeInfo {
+ open_workspace_id: None,
+ ..worktree.clone()
+ },
+ forced: false,
+ },
+ },
+ EventEnvelope {
+ event: EventKind::WorkspaceClosed,
+ data: EventData::WorkspaceClosed {
+ workspace_id: "w_2".into(),
+ workspace: Some(workspace.clone()),
+ },
+ },
+ ] {
+ let json = serde_json::to_string(&event).unwrap();
+ let restored: EventEnvelope = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, event);
+ }
+}
+
+#[test]
+fn plugin_link_list_unlink_round_trip() {
+ let link = Request {
+ id: "plugin_link".into(),
+ method: Method::PluginLink(PluginLinkParams {
+ path: "/plugins/worktree-bootstrap".into(),
+ enabled: true,
+ source: None,
+ }),
+ };
+ let json = serde_json::to_string(&link).unwrap();
+ assert!(json.contains("\"method\":\"plugin.link\""));
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, link);
+
+ let list = Request {
+ id: "plugin_list".into(),
+ method: Method::PluginList(PluginListParams {
+ plugin_id: Some("example.worktree-bootstrap".into()),
+ }),
+ };
+ let json = serde_json::to_string(&list).unwrap();
+ assert!(json.contains("\"method\":\"plugin.list\""));
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, list);
+
+ let unlink = Request {
+ id: "plugin_unlink".into(),
+ method: Method::PluginUnlink(PluginUnlinkParams {
+ plugin_id: "example.worktree-bootstrap".into(),
+ }),
+ };
+ let json = serde_json::to_string(&unlink).unwrap();
+ assert!(json.contains("\"method\":\"plugin.unlink\""));
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, unlink);
+
+ let plugin = InstalledPluginInfo {
+ plugin_id: "example.worktree-bootstrap".into(),
+ name: "Worktree Bootstrap".into(),
+ version: "0.1.0".into(),
+ description: Some("Prepare new worktrees".into()),
+ manifest_path: "/plugins/worktree-bootstrap/herdr-plugin.toml".into(),
+ plugin_root: "/plugins/worktree-bootstrap".into(),
+ enabled: true,
+ platforms: None,
+ build: vec![PluginManifestBuild {
+ platforms: None,
+ command: vec!["bun".into(), "install".into()],
+ }],
+ actions: vec![PluginManifestAction {
+ id: "bootstrap".into(),
+ title: "Bootstrap worktree".into(),
+ description: None,
+ contexts: vec![PluginActionContext::Workspace],
+ platforms: None,
+ command: vec!["bun".into(), "run".into(), "bootstrap.ts".into()],
+ }],
+ events: vec![PluginManifestEventHook {
+ on: "worktree.created".into(),
+ platforms: None,
+ command: vec!["bun".into(), "run".into(), "bootstrap.ts".into()],
+ }],
+ panes: vec![PluginManifestPane {
+ id: "board".into(),
+ title: "Board".into(),
+ description: None,
+ platforms: None,
+ placement: PluginPanePlacement::Overlay,
+ command: vec!["bun".into(), "run".into(), "board.ts".into()],
+ }],
+ link_handlers: vec![PluginManifestLinkHandler {
+ id: "github-pr".into(),
+ title: "Open GitHub PR".into(),
+ pattern: "^https://github.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$".into(),
+ action: "bootstrap".into(),
+ platforms: None,
+ }],
+ source: Default::default(),
+ warnings: vec![],
+ };
+
+ for response in [
+ SuccessResponse {
+ id: "plugin_link".into(),
+ result: ResponseResult::PluginLinked {
+ plugin: plugin.clone(),
+ },
+ },
+ SuccessResponse {
+ id: "plugin_list".into(),
+ result: ResponseResult::PluginList {
+ plugins: vec![plugin.clone()],
+ },
+ },
+ SuccessResponse {
+ id: "plugin_unlink".into(),
+ result: ResponseResult::PluginUnlinked {
+ plugin_id: plugin.plugin_id.clone(),
+ removed: true,
+ },
+ },
+ ] {
+ let json = serde_json::to_string(&response).unwrap();
+ let restored: SuccessResponse = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, response);
+ }
+}
+
+#[test]
+fn layout_export_apply_round_trip() {
+ let root = LayoutNode::Split {
+ direction: SplitDirection::Right,
+ ratio: 0.6,
+ first: Box::new(LayoutNode::Pane {
+ pane: LayoutPane {
+ label: Some("editor".into()),
+ cwd: Some("/repo".into()),
+ ..Default::default()
+ },
+ }),
+ second: Box::new(LayoutNode::Pane {
+ pane: LayoutPane {
+ label: Some("tests".into()),
+ command: Some(vec!["sh".into(), "-c".into(), "just test".into()]),
+ env: HashMap::from([("HERDR_ROLE".into(), "tests".into())]),
+ ..Default::default()
+ },
+ }),
+ };
+
+ let export = Request {
+ id: "layout_export".into(),
+ method: Method::LayoutExport(LayoutExportParams {
+ tab_id: Some("w1:1".into()),
+ pane_id: None,
+ }),
+ };
+ let json = serde_json::to_string(&export).unwrap();
+ assert!(json.contains("\"method\":\"layout.export\""));
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, export);
+
+ let apply = Request {
+ id: "layout_apply".into(),
+ method: Method::LayoutApply(LayoutApplyParams {
+ workspace_id: Some("w1".into()),
+ tab_id: None,
+ tab_label: Some("dev".into()),
+ focus: true,
+ root: root.clone(),
+ }),
+ };
+ let json = serde_json::to_string(&apply).unwrap();
+ assert!(json.contains("\"method\":\"layout.apply\""));
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, apply);
+
+ let response = SuccessResponse {
+ id: "layout_export".into(),
+ result: ResponseResult::LayoutExport {
+ layout: LayoutDescription {
+ workspace_id: "w1".into(),
+ tab_id: "w1:1".into(),
+ zoomed: false,
+ focused_pane_id: "w1-1".into(),
+ root,
+ },
+ },
+ };
+ let json = serde_json::to_string(&response).unwrap();
+ let restored: SuccessResponse = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, response);
+}
+
+#[test]
+fn create_response_round_trips_with_root_pane() {
+ let response = SuccessResponse {
+ id: "req_2".into(),
+ result: ResponseResult::TabCreated {
+ tab: TabInfo {
+ tab_id: "w_1:2".into(),
+ workspace_id: "w_1".into(),
+ number: 2,
+ label: "review".into(),
+ focused: false,
+ pane_count: 1,
+ agent_status: AgentStatus::Unknown,
+ },
+ root_pane: PaneInfo {
+ pane_id: "w_1-3".into(),
+ terminal_id: "term_example".into(),
+ workspace_id: "w_1".into(),
+ tab_id: "w_1:2".into(),
+ focused: false,
+ cwd: Some("/tmp/review".into()),
+ foreground_cwd: None,
+ label: None,
+ agent: None,
+ title: None,
+ display_agent: None,
+ agent_status: AgentStatus::Unknown,
+ custom_status: None,
+ state_labels: HashMap::new(),
+ agent_session: None,
+ revision: 0,
+ },
+ },
+ };
+
+ let json = serde_json::to_string(&response).unwrap();
+ assert!(json.contains("\"type\":\"tab_created\""));
+ assert!(json.contains("\"root_pane\""));
+ let restored: SuccessResponse = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, response);
+}
+
+#[test]
+fn error_response_round_trips() {
+ let response = ErrorResponse {
+ id: "req_1".into(),
+ error: ErrorBody {
+ code: "pane_not_found".into(),
+ message: "pane p_1 not found".into(),
+ },
+ };
+
+ let json = serde_json::to_string(&response).unwrap();
+ let restored: ErrorResponse = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, response);
+}
+
+#[test]
+fn event_wait_parses_typed_match() {
+ let json = r#"
+ {
+ "id": "req_9",
+ "method": "events.wait",
+ "params": {
+ "match_event": {
+ "event": "pane_agent_status_changed",
+ "pane_id": "p_1",
+ "agent_status": "done"
+ },
+ "timeout_ms": 30000
+ }
+ }
+ "#;
+
+ let request: Request = serde_json::from_str(json).unwrap();
+ let Method::EventsWait(params) = request.method else {
+ panic!("wrong method parsed");
+ };
+ assert_eq!(
+ params.match_event,
+ EventMatch::PaneAgentStatusChanged {
+ pane_id: "p_1".into(),
+ agent_status: AgentStatus::Done,
+ }
+ );
+}
+
+#[test]
+fn plugin_action_list_and_invoke_round_trips() {
+ let list = Request {
+ id: "req_plugin_action_list".into(),
+ method: Method::PluginActionList(PluginActionListParams {
+ plugin_id: Some("example.issue-flow".into()),
+ }),
+ };
+ let json = serde_json::to_value(&list).unwrap();
+ assert_eq!(json["method"], "plugin.action.list");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, list);
+
+ let invoke = Request {
+ id: "req_plugin_action_invoke".into(),
+ method: Method::PluginActionInvoke(PluginActionInvokeParams {
+ plugin_id: Some("example.issue-flow".into()),
+ action_id: "assign-issue".into(),
+ context: None,
+ }),
+ };
+ let json = serde_json::to_value(&invoke).unwrap();
+ assert_eq!(json["method"], "plugin.action.invoke");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, invoke);
+
+ let action_info = PluginActionInfo {
+ plugin_id: "example.issue-flow".into(),
+ action_id: "assign-issue".into(),
+ title: "Assign Issue".into(),
+ description: Some("Open the issue assignment UI".into()),
+ contexts: vec![PluginActionContext::Workspace, PluginActionContext::Pane],
+ command: vec!["assign".into(), "--issue".into()],
+ platforms: Some(vec![PluginPlatform::Linux, PluginPlatform::Macos]),
+ };
+ assert_eq!(
+ action_info.qualified_id(),
+ "example.issue-flow.assign-issue"
+ );
+ let json = serde_json::to_string(&action_info).unwrap();
+ let restored: PluginActionInfo = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, action_info);
+}
+
+#[test]
+fn plugin_pane_open_request_round_trips() {
+ let request = Request {
+ id: "req_plugin_pane".into(),
+ method: Method::PluginPaneOpen(PluginPaneOpenParams {
+ plugin_id: "example.board".into(),
+ entrypoint: "board".into(),
+ placement: Some(PluginPanePlacement::Zoomed),
+ workspace_id: None,
+ target_pane_id: Some("1-1".into()),
+ direction: Some(SplitDirection::Right),
+ cwd: Some("/tmp".into()),
+ focus: true,
+ env: [("HERDR_ROLE".to_string(), "board".to_string())].into(),
+ }),
+ };
+
+ let json = serde_json::to_value(&request).unwrap();
+ assert_eq!(json["method"], "plugin.pane.open");
+ assert_eq!(json["params"]["env"]["HERDR_ROLE"], "board");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, request);
+}
diff --git a/src/api/schema/workspaces.rs b/src/api/schema/workspaces.rs
new file mode 100644
index 00000000..d15cf09b
--- /dev/null
+++ b/src/api/schema/workspaces.rs
@@ -0,0 +1,46 @@
+use std::collections::HashMap;
+
+use serde::{Deserialize, Serialize};
+
+use super::common::AgentStatus;
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct WorkspaceCreateParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub cwd: Option<String>,
+ #[serde(default)]
+ pub focus: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub label: Option<String>,
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
+ pub env: HashMap<String, String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct WorkspaceRenameParams {
+ pub workspace_id: String,
+ pub label: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct WorkspaceInfo {
+ pub workspace_id: String,
+ pub number: usize,
+ pub label: String,
+ pub focused: bool,
+ pub pane_count: usize,
+ pub tab_count: usize,
+ pub active_tab_id: String,
+ pub agent_status: AgentStatus,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub worktree: Option<WorkspaceWorktreeInfo>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct WorkspaceWorktreeInfo {
+ pub repo_key: String,
+ pub repo_name: String,
+ pub repo_root: String,
+ pub checkout_path: String,
+ pub is_linked_worktree: bool,
+}
diff --git a/src/api/schema/worktrees.rs b/src/api/schema/worktrees.rs
new file mode 100644
index 00000000..f480edb1
--- /dev/null
+++ b/src/api/schema/worktrees.rs
@@ -0,0 +1,74 @@
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub struct WorktreeListParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub workspace_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub cwd: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub struct WorktreeCreateParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub workspace_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub cwd: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub branch: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub base: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub path: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub label: Option<String>,
+ #[serde(default)]
+ pub focus: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub struct WorktreeOpenParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub workspace_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub cwd: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub path: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub branch: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub label: Option<String>,
+ #[serde(default)]
+ pub focus: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct WorktreeRemoveParams {
+ pub workspace_id: String,
+ #[serde(default)]
+ pub force: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct WorktreeSourceInfo {
+ pub repo_key: String,
+ pub repo_name: String,
+ pub repo_root: String,
+ pub source_checkout_path: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub source_workspace_id: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct WorktreeInfo {
+ pub path: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub branch: Option<String>,
+ pub is_bare: bool,
+ pub is_detached: bool,
+ pub is_prunable: bool,
+ pub is_linked_worktree: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub open_workspace_id: Option<String>,
+ pub label: String,
+}