Skip to repository content1320 lines · 57.4 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:12:26.041Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
panel.rs
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