Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T05:16:00.893Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

panel.rs

1320 lines · 57.4 KB · rust
1//! The Full Auto launcher and concurrent run monitor.
2//!
3//! `OMEGA-DELTA-0020`: this was a dock panel of its own until the owner asked
4//! for Full Auto to be folded into the Omega chat UI. The views below did not
5//! change; `agent_ui::AgentPanel` renders them instead of the dock. Reaching
6//! this surface is still a dedicated entry rather than a composer flag,
7//! because reaching it is not the same act as starting a run.
8//!
9//! Mutations go through `omega_effectd`. There is no ordinary composer here.
10
11use std::{collections::HashMap, time::Duration};
12
13use agent_settings::AgentSettings;
14use anyhow::{Result, anyhow};
15use editor::Editor;
16use gpui::{
17    Action, AnyWindowHandle, App, Context, Entity, EventEmitter, FocusHandle, Focusable,
18    InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Task, WeakEntity,
19    Window, div, px,
20};
21use omega_effectd::{
22    AttentionDecision, OpenAgentsSession, OpenAgentsSessionPhase, SharedOmegaEffectdSupervisor,
23    openagents_session, shared_supervisor,
24};
25
26use crate::issue31_delivery::set_issue31_live_reading;
27use crate::issue31_observation::observe_issue31_full_auto;
28use omega_front_door::LaunchOrigin;
29use serde_json::Value;
30use settings::{NotifyWhenAgentWaiting, Settings as _};
31use ui::{Button, ButtonStyle, IconButton, IconName, Label, LabelSize, Tooltip, prelude::*};
32use util::ResultExt as _;
33use workspace::{
34    Workspace,
35    dock::{DockPosition, Panel, PanelEvent},
36    notifications::{NotificationId, simple_message_notification::MessageNotification},
37};
38use zed_actions::full_auto_panel::ToggleFocus;
39
40use crate::dispatch::FullAutoDispatch;
41use crate::draft::{
42    DEFAULT_DONE_CONDITION, DEFAULT_TURN_CAP, FULL_AUTO_ACTIVE_LIMIT, FULL_AUTO_WORKSPACE_REF,
43    FullAutoLauncherDraft, validate_launcher_draft,
44};
45use crate::evidence_chain::FullAutoEvidenceView;
46use crate::provider_roster::{ProviderAccountRow, parse_provider_accounts};
47
48const PANEL_KEY: &str = "FullAutoPanel";
49const ACTIVE_STATES: &[&str] = &["running", "pausing", "paused", "retrying", "stalled"];
50
51#[derive(Debug, Clone)]
52struct RunRow {
53    run_ref: String,
54    title: String,
55    state: String,
56}
57
58#[derive(Debug, Clone)]
59struct NativeEvidence {
60    project_ref: String,
61    worktree_ref: String,
62    git_head: Option<String>,
63}
64
65#[derive(Debug, Clone)]
66struct RunDetail {
67    run_ref: String,
68    title: String,
69    state: String,
70    objective: String,
71    done_condition: String,
72    workspace_ref: Option<String>,
73    lane: Option<String>,
74    turn_cap: u32,
75    successful_attempts: u32,
76    failed_attempts: u32,
77    stall_cause: Option<String>,
78    recovery_action: String,
79    objective_digest: Option<String>,
80    native_evidence: Option<NativeEvidence>,
81    turns: Vec<(String, String, String)>,
82    evidence: Option<FullAutoEvidenceView>,
83    evidence_detail: String,
84}
85
86#[derive(Debug, Clone)]
87struct CapacityLane {
88    lane: String,
89    state: String,
90    active_runs: u32,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
94struct FullAutoAttentionNotification {
95    title: &'static str,
96    body: &'static str,
97}
98
99enum SurfaceMode {
100    Launcher,
101    Run,
102}
103
104pub struct FullAutoPanel {
105    workspace: WeakEntity<Workspace>,
106    window: AnyWindowHandle,
107    focus_handle: FocusHandle,
108    mode: SurfaceMode,
109    /// The human gesture that opened the launch surface currently on screen.
110    ///
111    /// `OMEGA-DELTA-0030`. Recorded with the dispatch so a run says which
112    /// control a person operated to reach it. It is not a permission check:
113    /// the permission is the type of `FullAutoDispatch::from_validated`, which
114    /// cannot be called without one of these.
115    launch_origin: LaunchOrigin,
116    draft: FullAutoLauncherDraft,
117    objective_editor: Entity<Editor>,
118    title_editor: Entity<Editor>,
119    done_editor: Entity<Editor>,
120    turn_cap_editor: Entity<Editor>,
121    runs: Vec<RunRow>,
122    active_run: Option<RunDetail>,
123    active_run_ref: Option<String>,
124    capacity_lanes: Vec<CapacityLane>,
125    provider_accounts: Vec<ProviderAccountRow>,
126    provider_roster_detail: SharedString,
127    attention_dedup_keys: HashMap<String, String>,
128    attention_refresh_in_flight: bool,
129    status: SharedString,
130    openagents_session: OpenAgentsSession,
131    account_busy: bool,
132    supervisor: Option<SharedOmegaEffectdSupervisor>,
133    _refresh: Option<Task<()>>,
134}
135
136impl FullAutoPanel {
137    /// Build the Full Auto surface.
138    ///
139    /// `OMEGA-DELTA-0020`. This used to be private, reached only through
140    /// `load` from the dock-panel registration in `crates/zed`. The owner
141    /// asked for Full Auto to be folded into the Omega chat UI, so the agent
142    /// panel constructs it directly and hosts it as one of its surfaces.
143    ///
144    /// The `Panel` implementation below is deliberately kept. It is what a
145    /// re-dock would need, and deleting it would make the fold expensive to
146    /// reverse.
147    pub fn new(
148        workspace: WeakEntity<Workspace>,
149        window: &mut Window,
150        cx: &mut Context<Self>,
151    ) -> Self {
152        let objective_editor = cx.new(|cx| {
153            let mut editor = Editor::multi_line(window, cx);
154            editor.set_placeholder_text(
155                "Implement the outcome, verify it, and keep going until it works.",
156                window,
157                cx,
158            );
159            editor
160        });
161        let title_editor = cx.new(|cx| {
162            let mut editor = Editor::single_line(window, cx);
163            editor.set_placeholder_text("From the first line of the mission", window, cx);
164            editor
165        });
166        let done_editor = cx.new(|cx| {
167            let mut editor = Editor::multi_line(window, cx);
168            editor.set_placeholder_text(DEFAULT_DONE_CONDITION, window, cx);
169            editor
170        });
171        let turn_cap_editor = cx.new(|cx| {
172            let mut editor = Editor::single_line(window, cx);
173            editor.set_text(DEFAULT_TURN_CAP.to_string(), window, cx);
174            editor
175        });
176
177        let mut panel = Self {
178            workspace,
179            window: window.window_handle(),
180            focus_handle: cx.focus_handle(),
181            mode: SurfaceMode::Launcher,
182            launch_origin: LaunchOrigin::OpenLauncherAction,
183            draft: FullAutoLauncherDraft::default(),
184            objective_editor,
185            title_editor,
186            done_editor,
187            turn_cap_editor,
188            runs: Vec::new(),
189            active_run: None,
190            active_run_ref: None,
191            capacity_lanes: Vec::new(),
192            provider_accounts: Vec::new(),
193            provider_roster_detail: "Provider account roster has not been reported yet.".into(),
194            attention_dedup_keys: HashMap::new(),
195            attention_refresh_in_flight: false,
196            status: "Full Auto is a run, not a chat option.".into(),
197            openagents_session: openagents_session(cx),
198            account_busy: false,
199            supervisor: None,
200            _refresh: None,
201        };
202        panel.ensure_supervisor(cx);
203        panel.schedule_refresh(cx);
204        panel
205    }
206
207    fn open_launcher(&mut self, origin: LaunchOrigin, cx: &mut Context<Self>) {
208        self.launch_origin = origin;
209        self.mode = SurfaceMode::Launcher;
210        self.active_run_ref = None;
211        self.active_run = None;
212        self.draft.submitting = false;
213        self.draft.error = None;
214        cx.notify();
215    }
216
217    fn sync_draft_from_editors(&mut self, cx: &App) {
218        self.draft.objective = self.objective_editor.read(cx).text(cx);
219        self.draft.title = self.title_editor.read(cx).text(cx);
220        self.draft.done_condition = self.done_editor.read(cx).text(cx);
221        self.draft.turn_cap_text = self.turn_cap_editor.read(cx).text(cx);
222        if self.draft.workspace_ref.is_empty() {
223            self.draft.workspace_ref = FULL_AUTO_WORKSPACE_REF.to_string();
224        }
225    }
226
227    fn ensure_supervisor(&mut self, cx: &mut Context<Self>) {
228        if self.supervisor.is_some() {
229            return;
230        }
231        let handle = match shared_supervisor(cx) {
232            Ok(handle) => handle,
233            Err(error) => {
234                self.status = format!("omega-effectd unavailable ({error}).").into();
235                self.draft.error = Some(error.to_string());
236                cx.notify();
237                return;
238            }
239        };
240        let start_handle = handle.clone();
241        self.supervisor = Some(handle);
242        cx.spawn(async move |this, cx| {
243            let started = {
244                let mut guard = start_handle.lock().await;
245                guard.ensure_started().await
246            };
247            this.update(cx, |this, cx| {
248                this.status = match started {
249                    Ok(_) => "Connected to omega-effectd.".into(),
250                    Err(error) => format!("omega-effectd unavailable ({error}).").into(),
251                };
252                this.refresh_runs(cx);
253                cx.notify();
254            })
255            .ok();
256        })
257        .detach();
258    }
259
260    fn schedule_refresh(&mut self, cx: &mut Context<Self>) {
261        let executor = cx.background_executor().clone();
262        self._refresh = Some(cx.spawn(async move |this, cx| {
263            loop {
264                executor.timer(Duration::from_secs(3)).await;
265                if this.update(cx, |this, cx| this.refresh_runs(cx)).is_err() {
266                    break;
267                }
268            }
269        }));
270    }
271
272    fn refresh_runs(&mut self, cx: &mut Context<Self>) {
273        let Some(supervisor) = self.supervisor.clone() else {
274            return;
275        };
276        let active = self.active_run_ref.clone();
277        let previous_attention_keys = self.attention_dedup_keys.clone();
278        let permission_granted = AgentSettings::get_global(cx).notify_when_agent_waiting
279            != NotifyWhenAgentWaiting::Never;
280        let should_refresh_attention = !self.attention_refresh_in_flight;
281        if should_refresh_attention {
282            self.attention_refresh_in_flight = true;
283        }
284        cx.spawn(async move |this, cx| {
285            let listed = {
286                let mut guard = supervisor.lock().await;
287                guard.list_runs().await
288            };
289            let attention = if should_refresh_attention && let Ok(runs) = &listed {
290                let mut outcomes = Vec::new();
291                for run in runs
292                    .iter()
293                    .filter(|run| should_decide_attention(&run.state))
294                {
295                    let outcome = {
296                        let mut guard = supervisor.lock().await;
297                        guard
298                            .decide_attention(
299                                &run.run_ref,
300                                permission_granted,
301                                previous_attention_keys
302                                    .get(&run.run_ref)
303                                    .map(String::as_str),
304                            )
305                            .await
306                    };
307                    outcomes.push((run.run_ref.clone(), outcome));
308                }
309                outcomes
310            } else {
311                Vec::new()
312            };
313            let capacity = {
314                let mut guard = supervisor.lock().await;
315                guard.get_capacity().await.ok()
316            };
317            let detail = if let Some(run_ref) = active {
318                let mut guard = supervisor.lock().await;
319                let run = guard.get_run(&run_ref).await.ok();
320                let report = guard.get_report(&run_ref).await.ok();
321                let receipt = guard.get_receipt(&run_ref).await.ok();
322                (run, report, receipt)
323            } else {
324                (None, None, None)
325            };
326            // omega#49: the same poll feeds the phone. The Sarah host pump
327            // publishes whatever reading is recorded here, so a Full Auto view
328            // the desktop can see is one the owner's paired device can see too.
329            //
330            // omega#97: the polling itself moved to `issue31_observation`, so
331            // this view and a headless host take one reading path rather than
332            // two. It refuses an incomplete reading rather than shortening it —
333            // recording a partial one would publish a shorter run list than the
334            // host has, which reads on the phone as runs that ended — and the
335            // stamp is the observer's own clock reading, which no caller here
336            // can supply.
337            //
338            // Provider connection handoffs are deliberately not part of this
339            // reading. They are durable host records owned by the Sarah pump's
340            // ledger (omega#91) and survive a restart that this poll does not;
341            // carrying them here as well would give one fact two sources.
342            match observe_issue31_full_auto(&supervisor).await {
343                Ok(reading) => set_issue31_live_reading(reading),
344                Err(error) => {
345                    // Silence, not an empty view. The pump keeps publishing the
346                    // last reading the host actually took, and a host that has
347                    // never taken one publishes nothing at all.
348                    log::debug!("omega#97: Full Auto observation skipped ({})", error.token());
349                }
350            }
351            this.update(cx, |this, cx| {
352                if should_refresh_attention {
353                    this.attention_refresh_in_flight = false;
354                }
355                if let Ok(runs) = listed {
356                    this.runs = runs
357                        .into_iter()
358                        .map(|run| RunRow {
359                            run_ref: run.run_ref,
360                            title: run.title,
361                            state: run.state,
362                        })
363                        .collect();
364                    this.attention_dedup_keys
365                        .retain(|run_ref, _| this.runs.iter().any(|run| &run.run_ref == run_ref));
366                }
367                for (run_ref, outcome) in attention {
368                    match outcome {
369                        Ok(Some(decision)) => {
370                            this.attention_dedup_keys
371                                .insert(run_ref, decision.dedup_key.clone());
372                            if let Some(notification) =
373                                notification_from_attention_decision(&decision)
374                            {
375                                this.show_attention_notification(notification, cx);
376                            }
377                        }
378                        Ok(None) => {}
379                        Err(_) => {
380                            this.status =
381                                "Full Auto attention status is temporarily unavailable.".into();
382                        }
383                    }
384                }
385                if let Some(value) = capacity {
386                    this.capacity_lanes = parse_capacity_lanes(value.clone());
387                    this.provider_accounts = parse_provider_accounts(&value);
388                    this.provider_roster_detail = if this.provider_accounts.is_empty() {
389                        "No provider accounts were reported. A capacity lane is not an account."
390                            .into()
391                    } else {
392                        format!(
393                            "{} connected provider account(s).",
394                            this.provider_accounts.len()
395                        )
396                        .into()
397                    };
398                }
399                if let Some(value) = detail.0 {
400                    if let Ok(mut parsed) = parse_detail(value) {
401                        if let Some(receipt) = detail.2.as_ref() {
402                            parsed.objective_digest = receipt
403                                .get("objectiveDigest")
404                                .and_then(|v| v.as_str())
405                                .map(str::to_string);
406                        }
407                        parsed.evidence = detail.1.as_ref().zip(detail.2.as_ref()).and_then(
408                            |(report, receipt)| FullAutoEvidenceView::from_records(report, receipt),
409                        );
410                        parsed.evidence_detail = if parsed.evidence.is_some() {
411                            "Host-verified evidence chain".into()
412                        } else {
413                            "Evidence chain unavailable or cross-links did not verify.".into()
414                        };
415                        this.active_run = Some(parsed);
416                        this.mode = SurfaceMode::Run;
417                    }
418                }
419                cx.notify();
420            })
421            .ok();
422        })
423        .detach();
424    }
425
426    fn show_attention_notification(
427        &self,
428        notification: FullAutoAttentionNotification,
429        cx: &mut Context<Self>,
430    ) {
431        self.workspace
432            .update(cx, |workspace, cx| {
433                workspace.show_notification(
434                    NotificationId::unique::<FullAutoAttentionNotification>(),
435                    cx,
436                    |cx| {
437                        cx.new(|cx| {
438                            MessageNotification::new(notification.body, cx)
439                                .with_title(notification.title)
440                                .content_icon(IconName::Warning, Color::Warning)
441                                .show_suppress_button(false)
442                        })
443                    },
444                );
445            })
446            .log_err();
447        cx.update_window(self.window, |_, window, _| window.request_attention())
448            .log_err();
449    }
450
451    /// Dispatch the drafted run to `omega-effectd`.
452    ///
453    /// `OMEGA-DELTA-0030`. The wire form is built by
454    /// [`FullAutoDispatch::params`] rather than inline here, so the start
455    /// request's shape is a type with tests rather than whatever this
456    /// expression happened to contain. In particular a start request has no
457    /// field for an `evidence` block, a `decisionRef`, or an
458    /// `authorityReceiptRef` — a requester that could name those could forge
459    /// them, and evidence is minted by the host at the completion-admission
460    /// gate.
461    fn start_run(&mut self, cx: &mut Context<Self>) {
462        self.sync_draft_from_editors(cx);
463        let validation = validate_launcher_draft(&self.draft);
464        let (project_ref, worktree_ref) = self
465            .workspace
466            .upgrade()
467            .map(|workspace| {
468                let project = workspace.read(cx).project().clone();
469                let project_id = project.entity_id().as_u64();
470                let worktree_id = project
471                    .read(cx)
472                    .worktrees(cx)
473                    .next()
474                    .map(|wt| wt.read(cx).id());
475                (
476                    Some(format!("project.{project_id}")),
477                    worktree_id.map(|id| format!("worktree.{}", id.to_proto())),
478                )
479            })
480            .unwrap_or((None, None));
481        let dispatch = match FullAutoDispatch::from_validated(
482            self.launch_origin,
483            &self.draft,
484            &validation,
485            project_ref.as_deref(),
486            worktree_ref.as_deref(),
487        ) {
488            Ok(dispatch) => dispatch,
489            Err(refusal) => {
490                self.draft.submitting = false;
491                self.draft.error = Some(
492                    validation
493                        .message
494                        .clone()
495                        .unwrap_or_else(|| refusal.message().to_string()),
496                );
497                cx.notify();
498                return;
499            }
500        };
501        // Checked after the draft, so a person who has not written an
502        // objective is told that first. The engine being down is a second
503        // problem, and reporting it instead would send them looking at the
504        // wrong thing.
505        let Some(supervisor) = self.supervisor.clone() else {
506            self.draft.error = Some("omega-effectd is not connected.".into());
507            cx.notify();
508            return;
509        };
510        self.draft.submitting = true;
511        self.draft.error = None;
512        let params = dispatch.params();
513        cx.notify();
514        cx.spawn(async move |this, cx| {
515            let started = {
516                let mut guard = supervisor.lock().await;
517                guard.start_run(params).await
518            };
519            this.update(cx, |this, cx| {
520                this.draft.submitting = false;
521                match started {
522                    Ok(value) => match parse_detail(value) {
523                        Ok(detail) => {
524                            this.active_run_ref = Some(detail.run_ref.clone());
525                            this.active_run = Some(detail);
526                            this.mode = SurfaceMode::Run;
527                            this.status = "Full Auto run started.".into();
528                            this.refresh_runs(cx);
529                        }
530                        Err(error) => this.draft.error = Some(error.to_string()),
531                    },
532                    Err(error) => this.draft.error = Some(error.to_string()),
533                }
534                cx.notify();
535            })
536            .ok();
537        })
538        .detach();
539    }
540
541    fn mutate_active(&mut self, method: &'static str, cx: &mut Context<Self>) {
542        let Some(run_ref) = self.active_run_ref.clone() else {
543            return;
544        };
545        let Some(supervisor) = self.supervisor.clone() else {
546            return;
547        };
548        cx.spawn(async move |this, cx| {
549            let result = {
550                let mut guard = supervisor.lock().await;
551                match method {
552                    "pause" => guard.pause_run(&run_ref).await,
553                    "resume" => guard.resume_run(&run_ref).await,
554                    "handoff-claude" => guard.handoff_run(&run_ref, "claude-local").await,
555                    "handoff-codex" => guard.handoff_run(&run_ref, "codex-local").await,
556                    "stop" => guard.stop_run(&run_ref).await,
557                    "retry" => guard.retry_run(&run_ref).await,
558                    _ => Err(omega_effectd::SupervisorError::Anyhow(anyhow!(
559                        "unknown mutation"
560                    ))),
561                }
562            };
563            this.update(cx, |this, cx| {
564                match result {
565                    Ok(value) => match parse_detail(value) {
566                        Ok(detail) => {
567                            this.status = "Full Auto run updated.".into();
568                            this.active_run = Some(detail);
569                        }
570                        Err(error) => {
571                            this.status = format!("Full Auto response was invalid: {error}").into();
572                        }
573                    },
574                    Err(error) => {
575                        this.status = format!("Full Auto action failed: {error}").into();
576                    }
577                }
578                this.refresh_runs(cx);
579                cx.notify();
580            })
581            .ok();
582        })
583        .detach();
584    }
585
586    fn open_run(&mut self, run_ref: String, cx: &mut Context<Self>) {
587        self.active_run_ref = Some(run_ref);
588        self.mode = SurfaceMode::Run;
589        self.refresh_runs(cx);
590        cx.notify();
591    }
592
593    fn connect_openagents(&mut self, cx: &mut Context<Self>) {
594        if self.account_busy {
595            return;
596        }
597        self.account_busy = true;
598        self.status = "Opening OpenAgents authorization in your browser…".into();
599        let session = self.openagents_session.clone();
600        cx.spawn(async move |this, cx| {
601            let phase = session.connect(cx).await;
602            this.update(cx, |this, cx| {
603                this.account_busy = false;
604                this.status = match phase {
605                    OpenAgentsSessionPhase::Ready => "OpenAgents Sync account connected.".into(),
606                    OpenAgentsSessionPhase::SignedOut => {
607                        "OpenAgents authorization was cancelled.".into()
608                    }
609                    _ => "OpenAgents account could not be verified. Reconnect to try again.".into(),
610                };
611                cx.notify();
612            })
613            .ok();
614        })
615        .detach();
616        cx.notify();
617    }
618
619    fn disconnect_openagents(&mut self, cx: &mut Context<Self>) {
620        if self.account_busy {
621            return;
622        }
623        self.account_busy = true;
624        self.status = "Revoking OpenAgents account credentials…".into();
625        let session = self.openagents_session.clone();
626        cx.spawn(async move |this, cx| {
627            let phase = session.disconnect(cx).await;
628            this.update(cx, |this, cx| {
629                this.account_busy = false;
630                this.status = match phase {
631                    OpenAgentsSessionPhase::SignedOut => {
632                        "OpenAgents Sync account disconnected.".into()
633                    }
634                    _ => "OpenAgents could not prove both credentials were revoked. The local keychain record was retained.".into(),
635                };
636                cx.notify();
637            })
638            .ok();
639        })
640        .detach();
641        cx.notify();
642    }
643}
644
645fn should_decide_attention(state: &str) -> bool {
646    matches!(state, "retrying" | "stalled")
647}
648
649fn notification_from_attention_decision(
650    decision: &AttentionDecision,
651) -> Option<FullAutoAttentionNotification> {
652    if !decision.notify {
653        return None;
654    }
655    match decision.title.as_str() {
656        "Full Auto stalled" => Some(FullAutoAttentionNotification {
657            title: "Full Auto stalled",
658            body: "A Full Auto run stalled and needs your attention.",
659        }),
660        "Full Auto retrying" => Some(FullAutoAttentionNotification {
661            title: "Full Auto retrying",
662            body: "A Full Auto run is retrying after an interruption.",
663        }),
664        _ => None,
665    }
666}
667
668fn parse_detail(value: Value) -> Result<RunDetail> {
669    Ok(RunDetail {
670        run_ref: value
671            .get("runRef")
672            .and_then(|v| v.as_str())
673            .ok_or_else(|| anyhow!("runRef"))?
674            .to_string(),
675        title: value
676            .get("title")
677            .and_then(|v| v.as_str())
678            .unwrap_or("Full Auto")
679            .to_string(),
680        state: value
681            .get("state")
682            .and_then(|v| v.as_str())
683            .unwrap_or("unknown")
684            .to_string(),
685        objective: value
686            .get("objective")
687            .and_then(|v| v.as_str())
688            .unwrap_or("")
689            .to_string(),
690        done_condition: value
691            .get("doneCondition")
692            .and_then(|v| v.as_str())
693            .unwrap_or("")
694            .to_string(),
695        workspace_ref: value
696            .get("workspaceRef")
697            .and_then(|v| v.as_str())
698            .map(str::to_string),
699        lane: value
700            .get("lane")
701            .and_then(|v| v.as_str())
702            .map(str::to_string),
703        turn_cap: value
704            .get("turnCap")
705            .and_then(|v| v.as_u64())
706            .unwrap_or(DEFAULT_TURN_CAP as u64) as u32,
707        successful_attempts: value
708            .get("successfulAttempts")
709            .and_then(|v| v.as_u64())
710            .unwrap_or(0) as u32,
711        failed_attempts: value
712            .get("failedAttempts")
713            .and_then(|v| v.as_u64())
714            .unwrap_or(0) as u32,
715        stall_cause: value
716            .get("stallCause")
717            .and_then(|v| v.as_str())
718            .map(str::to_string),
719        recovery_action: value
720            .get("recoveryAction")
721            .and_then(|v| v.as_str())
722            .unwrap_or("none")
723            .to_string(),
724        objective_digest: None,
725        native_evidence: value.get("nativeEvidence").and_then(|evidence| {
726            Some(NativeEvidence {
727                project_ref: evidence.get("projectRef")?.as_str()?.to_string(),
728                worktree_ref: evidence.get("worktreeRef")?.as_str()?.to_string(),
729                git_head: evidence
730                    .get("gitHead")
731                    .and_then(|v| v.as_str())
732                    .map(str::to_string),
733            })
734        }),
735        turns: value
736            .get("turns")
737            .and_then(|v| v.as_array())
738            .map(|turns| {
739                turns
740                    .iter()
741                    .filter_map(|turn| {
742                        Some((
743                            turn.get("lane")?.as_str()?.to_string(),
744                            turn.get("outcomeSummary")?.as_str()?.to_string(),
745                            turn.get("createdAt")?.as_str()?.to_string(),
746                        ))
747                    })
748                    .collect()
749            })
750            .unwrap_or_default(),
751        evidence: None,
752        evidence_detail: "Evidence chain has not been loaded.".into(),
753    })
754}
755
756fn parse_capacity_lanes(value: Value) -> Vec<CapacityLane> {
757    value
758        .get("lanes")
759        .and_then(|v| v.as_array())
760        .map(|lanes| {
761            lanes
762                .iter()
763                .filter_map(|lane| {
764                    Some(CapacityLane {
765                        lane: lane.get("lane")?.as_str()?.to_string(),
766                        state: lane.get("state")?.as_str()?.to_string(),
767                        active_runs: lane.get("activeRuns")?.as_u64()? as u32,
768                    })
769                })
770                .collect()
771        })
772        .unwrap_or_default()
773}
774
775impl EventEmitter<PanelEvent> for FullAutoPanel {}
776
777impl Focusable for FullAutoPanel {
778    fn focus_handle(&self, _: &App) -> FocusHandle {
779        self.focus_handle.clone()
780    }
781}
782
783impl Render for FullAutoPanel {
784    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
785        let can_start = {
786            self.sync_draft_from_editors(cx);
787            validate_launcher_draft(&self.draft).ok
788                && !self.draft.submitting
789                && self.supervisor.is_some()
790        };
791        let active_count = self
792            .runs
793            .iter()
794            .filter(|run| ACTIVE_STATES.contains(&run.state.as_str()))
795            .count()
796            .min(FULL_AUTO_ACTIVE_LIMIT);
797        let account_phase = self.openagents_session.phase();
798        let account_connected = account_phase == OpenAgentsSessionPhase::Ready;
799
800        h_flex()
801            .id("full-auto-panel")
802            .size_full()
803            .track_focus(&self.focus_handle)
804            .child(
805                v_flex()
806                    .flex_1()
807                    .size_full()
808                    .gap_2()
809                    .p_3()
810                    .child(Label::new(self.status.clone()).color(Color::Muted))
811                    .child(
812                        h_flex()
813                            .justify_between()
814                            .child(
815                                v_flex()
816                                    .child(Label::new("OpenAgents Sync"))
817                                    .child(
818                                        Label::new(account_phase.label()).color(if account_connected {
819                                            Color::Success
820                                        } else {
821                                            Color::Muted
822                                        }),
823                                    ),
824                            )
825                            .child(if account_connected {
826                                Button::new("full-auto-openagents-disconnect", "Disconnect")
827                                    .style(ButtonStyle::Subtle)
828                                    .disabled(self.account_busy)
829                                    .on_click(cx.listener(|this, _, _, cx| {
830                                        this.disconnect_openagents(cx)
831                                    }))
832                            } else {
833                                Button::new(
834                                    "full-auto-openagents-connect",
835                                    if account_phase == OpenAgentsSessionPhase::SignedOut {
836                                        "Connect"
837                                    } else {
838                                        "Reconnect"
839                                    },
840                                )
841                                .style(ButtonStyle::Subtle)
842                                .disabled(self.account_busy)
843                                .on_click(cx.listener(|this, _, _, cx| {
844                                    this.connect_openagents(cx)
845                                }))
846                            }),
847                    )
848                    .child(Label::new("Provider accounts").size(LabelSize::Large))
849                    .child(
850                        Label::new(self.provider_roster_detail.clone())
851                            .color(Color::Muted)
852                            .size(LabelSize::Small),
853                    )
854                    .children(self.provider_accounts.iter().enumerate().map(|(index, account)| {
855                        v_flex()
856                            .id(("full-auto-provider-account", index))
857                            .gap_0p5()
858                            .border_1()
859                            .border_color(cx.theme().colors().border)
860                            .rounded_md()
861                            .p_2()
862                            .child(Label::new(format!("{} · {}", account.provider, account.label)))
863                            .child(
864                                Label::new(format!(
865                                    "{} · quota {} · lane {}",
866                                    account.readiness, account.quota, account.lane
867                                ))
868                                .color(Color::Muted),
869                            )
870                            .child(
871                                Label::new(format!("Account ref: {}", account.account_ref))
872                                    .color(Color::Muted)
873                                    .size(LabelSize::Small),
874                            )
875                    }))
876                    .child(
877                        Label::new(
878                            "Connect or reauthenticate provider accounts through Omega’s native Agent authentication flow. Credentials stay on this device; Omega never copies the default Codex home.",
879                        )
880                        .color(Color::Muted)
881                        .size(LabelSize::Small),
882                    )
883                    .map(|this| match self.mode {
884                        SurfaceMode::Launcher => this
885                            .child(Label::new("Full Auto").size(LabelSize::Large))
886                            .child(
887                                Label::new(
888                                    "Describe the outcome once. Providers keep moving until it is done or needs you.",
889                                )
890                                .color(Color::Muted),
891                            )
892                            .child(Label::new("What should Full Auto accomplish?"))
893                            .child(
894                                div()
895                                    .border_1()
896                                    .border_color(cx.theme().colors().border)
897                                    .rounded_md()
898                                    .h(px(120.))
899                                    .child(self.objective_editor.clone()),
900                            )
901                            .child(
902                                h_flex()
903                                    .gap_2()
904                                    .child(
905                                        Label::new(FULL_AUTO_WORKSPACE_REF).color(Color::Muted),
906                                    )
907                                    .child(Label::new("Codex → Claude").color(Color::Muted))
908                                    .child(Label::new(format!("{DEFAULT_TURN_CAP} turns")).color(Color::Muted)),
909                            )
910                            .when(!self.capacity_lanes.is_empty(), |this| {
911                                this.child(
912                                    Label::new(format!(
913                                        "Capacity: {}",
914                                        self.capacity_lanes
915                                            .iter()
916                                            .map(|lane| {
917                                                format!(
918                                                    "{}={}({})",
919                                                    lane.lane, lane.state, lane.active_runs
920                                                )
921                                            })
922                                            .collect::<Vec<_>>()
923                                            .join(" · ")
924                                    ))
925                                    .color(Color::Muted),
926                                )
927                            })
928                            .child(
929                                Button::new("full-auto-advanced-toggle", if self.draft.advanced_open {
930                                    "Hide Advanced"
931                                } else {
932                                    "Advanced"
933                                })
934                                .style(ButtonStyle::Subtle)
935                                .on_click(cx.listener(|this, _, _, cx| {
936                                    this.draft.advanced_open = !this.draft.advanced_open;
937                                    cx.notify();
938                                })),
939                            )
940                            .when(self.draft.advanced_open, |this| {
941                                this.child(Label::new("Title"))
942                                    .child(
943                                        div()
944                                            .border_1()
945                                            .border_color(cx.theme().colors().border)
946                                            .rounded_md()
947                                            .child(self.title_editor.clone()),
948                                    )
949                                    .child(Label::new("Done condition"))
950                                    .child(
951                                        div()
952                                            .border_1()
953                                            .border_color(cx.theme().colors().border)
954                                            .rounded_md()
955                                            .h(px(72.))
956                                            .child(self.done_editor.clone()),
957                                    )
958                                    .child(Label::new("Turn cap"))
959                                    .child(
960                                        div()
961                                            .border_1()
962                                            .border_color(cx.theme().colors().border)
963                                            .rounded_md()
964                                            .child(self.turn_cap_editor.clone()),
965                                    )
966                            })
967                            .when_some(self.draft.error.clone(), |this, error| {
968                                this.child(Label::new(error).color(Color::Error))
969                            })
970                            .child(
971                                h_flex()
972                                    .gap_2()
973                                    .child(
974                                        Button::new("full-auto-start", "Start Full Auto")
975                                            .style(ButtonStyle::Filled)
976                                            .disabled(!can_start)
977                                            .on_click(cx.listener(|this, _, _, cx| this.start_run(cx))),
978                                    )
979                                    .child(
980                                        Button::new("full-auto-cancel", "Cancel")
981                                            .style(ButtonStyle::Subtle)
982                                            .on_click(cx.listener(|this, _, window, cx| {
983                                                this.draft = FullAutoLauncherDraft::default();
984                                                this.objective_editor.update(cx, |editor, cx| {
985                                                    editor.clear(window, cx);
986                                                });
987                                                cx.notify();
988                                            })),
989                                    ),
990                            ),
991                        SurfaceMode::Run => {
992                            let Some(run) = self.active_run.clone() else {
993                                return this.child(Label::new(
994                                    "This Full Auto run could not be found.",
995                                ));
996                            };
997                            let can_pause = run.state == "running";
998                            let can_resume = run.state == "paused";
999                            let handoff_target = match run.lane.as_deref() {
1000                                Some("codex-local") if can_resume => Some((
1001                                    "Handoff to Claude",
1002                                    "handoff-claude",
1003                                )),
1004                                Some("claude-local") if can_resume => {
1005                                    Some(("Handoff to Codex", "handoff-codex"))
1006                                }
1007                                _ => None,
1008                            };
1009                            let can_retry =
1010                                run.state == "stalled" && run.recovery_action == "retry_now";
1011                            let terminal = ["completed", "failed", "stopped", "cap_reached"]
1012                                .contains(&run.state.as_str());
1013                            this.child(Label::new(run.title.clone()).size(LabelSize::Large))
1014                                .child(Label::new(format!("State: {}", run.state)).color(Color::Accent))
1015                                .when_some(run.stall_cause.clone(), |this, cause| {
1016                                    this.child(
1017                                        Label::new(format!(
1018                                            "Stall: {cause} · recovery: {}",
1019                                            run.recovery_action
1020                                        ))
1021                                        .color(Color::Warning),
1022                                    )
1023                                })
1024                                .child(
1025                                    Label::new(format!(
1026                                        "Workspace: {} · Provider: {} · Cap: {}/{}",
1027                                        run.workspace_ref.as_deref().unwrap_or("—"),
1028                                        run.lane.as_deref().unwrap_or("—"),
1029                                        run.successful_attempts + run.failed_attempts,
1030                                        run.turn_cap
1031                                    ))
1032                                    .color(Color::Muted),
1033                                )
1034                                .when_some(run.native_evidence.clone(), |this, evidence| {
1035                                    this.child(
1036                                        Label::new(format!(
1037                                            "Native: {} · {} · git {}",
1038                                            evidence.project_ref,
1039                                            evidence.worktree_ref,
1040                                            evidence.git_head.as_deref().unwrap_or("—")
1041                                        ))
1042                                        .color(Color::Muted),
1043                                    )
1044                                })
1045                                .when(!self.capacity_lanes.is_empty(), |this| {
1046                                    this.child(
1047                                        Label::new(format!(
1048                                            "Capacity: {}",
1049                                            self.capacity_lanes
1050                                                .iter()
1051                                                .map(|lane| {
1052                                                    format!(
1053                                                        "{}={}({})",
1054                                                        lane.lane, lane.state, lane.active_runs
1055                                                    )
1056                                                })
1057                                                .collect::<Vec<_>>()
1058                                                .join(" · ")
1059                                        ))
1060                                        .color(Color::Muted),
1061                                    )
1062                                })
1063                                .child(Label::new(run.objective.clone()))
1064                                .when_some(run.objective_digest.clone(), |this, digest| {
1065                                    this.child(
1066                                        Label::new(format!("Receipt objective digest: {digest}"))
1067                                            .color(Color::Muted),
1068                                    )
1069                                })
1070                                .child(
1071                                    Label::new(format!("Done when: {}", run.done_condition))
1072                                        .color(Color::Muted),
1073                                )
1074                                .child(
1075                                    h_flex()
1076                                        .gap_2()
1077                                        .when(can_pause, |row| {
1078                                            row.child(
1079                                                Button::new("full-auto-pause", "Pause").on_click(
1080                                                    cx.listener(|this, _, _, cx| {
1081                                                        this.mutate_active("pause", cx)
1082                                                    }),
1083                                                ),
1084                                            )
1085                                        })
1086                                        .when(can_resume, |row| {
1087                                            row.child(
1088                                                Button::new("full-auto-resume", "Resume").on_click(
1089                                                    cx.listener(|this, _, _, cx| {
1090                                                        this.mutate_active("resume", cx)
1091                                                    }),
1092                                                ),
1093                                            )
1094                                        })
1095                                        .when_some(handoff_target, |row, (label, method)| {
1096                                            row.child(
1097                                                Button::new("full-auto-handoff", label)
1098                                                    .on_click(cx.listener(move |this, _, _, cx| {
1099                                                        this.mutate_active(method, cx)
1100                                                    })),
1101                                            )
1102                                        })
1103                                        .when(can_retry, |row| {
1104                                            row.child(
1105                                                Button::new("full-auto-retry", "Retry now")
1106                                                    .on_click(cx.listener(|this, _, _, cx| {
1107                                                        this.mutate_active("retry", cx)
1108                                                    })),
1109                                            )
1110                                        })
1111                                        .when(!terminal, |row| {
1112                                            row.child(
1113                                                Button::new("full-auto-stop", "Stop")
1114                                                    .style(ButtonStyle::Tinted(ui::TintColor::Error))
1115                                                    .on_click(cx.listener(|this, _, _, cx| {
1116                                                        this.mutate_active("stop", cx)
1117                                                    })),
1118                                            )
1119                                        })
1120                                        .child(
1121                                            Button::new("full-auto-new", "New run")
1122                                                .style(ButtonStyle::Subtle)
1123                                                .on_click(cx.listener(|this, _, _, cx| {
1124                                                    this.open_launcher(
1125                                                        LaunchOrigin::RunSurfaceNewRun,
1126                                                        cx,
1127                                                    )
1128                                                })),
1129                                        ),
1130                                )
1131                                .child(
1132                                    Label::new(
1133                                        "Read-only conversation joins through Sync in FA-05. Turns below are service-backed.",
1134                                    )
1135                                    .color(Color::Muted),
1136                                )
1137                                .child(Label::new("Turns"))
1138                                .when(run.turns.is_empty(), |this| {
1139                                    this.child(
1140                                        Label::new("No turns recorded yet.").color(Color::Muted),
1141                                    )
1142                                })
1143                                .children(run.turns.into_iter().map(|(lane, summary, when)| {
1144                                    h_flex()
1145                                        .gap_2()
1146                                        .child(Label::new(lane).color(Color::Accent))
1147                                        .child(Label::new(summary))
1148                                        .child(Label::new(when).color(Color::Muted))
1149                                }))
1150                                .child(Label::new(run.evidence_detail).color(Color::Muted))
1151                                .when_some(run.evidence, |this, evidence| {
1152                                    this.child(
1153                                        v_flex()
1154                                            .id("full-auto-evidence-chain")
1155                                            .gap_1()
1156                                            .border_1()
1157                                            .border_color(cx.theme().colors().border)
1158                                            .rounded_md()
1159                                            .p_2()
1160                                            .children(evidence.fields.into_iter().map(|field| {
1161                                                Label::new(field.line()).size(LabelSize::Small)
1162                                            })),
1163                                    )
1164                                })
1165                        }
1166                    }),
1167            )
1168            .child(
1169                v_flex()
1170                    .id("full-auto-monitor")
1171                    .w(px(220.))
1172                    .gap_2()
1173                    .p_2()
1174                    .border_l_1()
1175                    .border_color(cx.theme().colors().border)
1176                    .child(
1177                        h_flex()
1178                            .justify_between()
1179                            .child(
1180                                v_flex()
1181                                    .child(Label::new("Runs"))
1182                                    .child(Label::new(if active_count == 0 {
1183                                        "No active runs".into()
1184                                    } else {
1185                                        format!("{active_count} active")
1186                                    }).color(Color::Muted)),
1187                            )
1188                            .child(
1189                                IconButton::new("full-auto-monitor-new", IconName::Plus)
1190                                    .tooltip(Tooltip::text("New Full Auto run"))
1191                                    .on_click(cx.listener(|this, _, _, cx| this.open_launcher(LaunchOrigin::RunMonitorNewRun, cx))),
1192                            ),
1193                    )
1194                    .children(self.runs.iter().enumerate().take(FULL_AUTO_ACTIVE_LIMIT + 6).map(|(index, run)| {
1195                        let run_ref = run.run_ref.clone();
1196                        let selected = self.active_run_ref.as_deref() == Some(run.run_ref.as_str());
1197                        Button::new(
1198                            ("full-auto-run-row", index),
1199                            format!("{} · {}", run.title, run.state),
1200                        )
1201                        .style(if selected {
1202                            ButtonStyle::Filled
1203                        } else {
1204                            ButtonStyle::Subtle
1205                        })
1206                        .full_width()
1207                        .on_click(cx.listener(move |this, _, _, cx| {
1208                            this.open_run(run_ref.clone(), cx);
1209                        }))
1210                    })),
1211            )
1212    }
1213}
1214
1215impl Panel for FullAutoPanel {
1216    fn persistent_name() -> &'static str {
1217        "FullAutoPanel"
1218    }
1219
1220    fn panel_key() -> &'static str {
1221        PANEL_KEY
1222    }
1223
1224    fn position(&self, _: &Window, _: &App) -> DockPosition {
1225        DockPosition::Right
1226    }
1227
1228    fn position_is_valid(&self, _: DockPosition) -> bool {
1229        true
1230    }
1231
1232    fn set_position(&mut self, _: DockPosition, _: &mut Window, _: &mut Context<Self>) {}
1233
1234    fn default_size(&self, _: &Window, _: &App) -> Pixels {
1235        px(520.)
1236    }
1237
1238    fn icon(&self, _: &Window, _: &App) -> Option<IconName> {
1239        Some(IconName::OmegaAgent)
1240    }
1241
1242    fn icon_tooltip(&self, _: &Window, _: &App) -> Option<&'static str> {
1243        Some("Full Auto")
1244    }
1245
1246    fn toggle_action(&self) -> Box<dyn Action> {
1247        Box::new(ToggleFocus)
1248    }
1249
1250    fn activation_priority(&self) -> u32 {
1251        8
1252    }
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257    use super::*;
1258
1259    #[test]
1260    fn attention_notifications_use_only_allowlisted_content() {
1261        let decision = AttentionDecision {
1262            notify: true,
1263            dedup_key: "run.secret:stalled:/Users/owner/private".into(),
1264            title: "Full Auto stalled".into(),
1265            body: "SECRET_OBJECTIVE /Users/owner/private auth.json bearer-token".into(),
1266        };
1267
1268        let notification = notification_from_attention_decision(&decision)
1269            .expect("known typed attention state should notify");
1270        assert_eq!(notification.title, "Full Auto stalled");
1271        assert_eq!(
1272            notification.body,
1273            "A Full Auto run stalled and needs your attention."
1274        );
1275        let rendered = format!("{} {}", notification.title, notification.body);
1276        for secret in [
1277            "SECRET_OBJECTIVE",
1278            "/Users/owner/private",
1279            "auth.json",
1280            "bearer-token",
1281            &decision.body,
1282            &decision.dedup_key,
1283        ] {
1284            assert!(!rendered.contains(secret));
1285        }
1286    }
1287
1288    #[test]
1289    fn routine_or_untrusted_attention_outcomes_do_not_notify() {
1290        for state in [
1291            "draft",
1292            "running",
1293            "pausing",
1294            "paused",
1295            "completed",
1296            "failed",
1297            "stopped",
1298            "cap_reached",
1299        ] {
1300            assert!(!should_decide_attention(state), "routine state {state}");
1301        }
1302
1303        let denied = AttentionDecision {
1304            notify: false,
1305            dedup_key: "run.x:stalled:none".into(),
1306            title: "Full Auto stalled".into(),
1307            body: "private run title".into(),
1308        };
1309        assert!(notification_from_attention_decision(&denied).is_none());
1310
1311        let untrusted = AttentionDecision {
1312            notify: true,
1313            dedup_key: "run.x:stalled:none".into(),
1314            title: "Full Auto stalled: /private/path".into(),
1315            body: "raw provider error".into(),
1316        };
1317        assert!(notification_from_attention_decision(&untrusted).is_none());
1318    }
1319}
1320
Served at tenant.openagents/omega Member data and write actions are omitted.