Skip to main content

starweaver_cli/
service.rs

1//! CLI service layer over local storage and SDK execution.
2#![allow(clippy::redundant_pub_crate)]
3
4use std::{collections::BTreeSet, fs, path::Path, sync::mpsc, thread, time::Duration};
5
6use serde_json::{Value, json};
7use starweaver_agent::ResumableState;
8use starweaver_core::sdk_name;
9use starweaver_oauth_provider::create_oauth_refresh_supervisor_for_models_with_options;
10use starweaver_runtime::AgentStreamRecord;
11use starweaver_session::{
12    ApprovalStatus, RunRecord, RunStatus, SessionSearchFilter, SessionSearchGranularity,
13    SessionSearchQuery, SessionSearchSource, SessionStatus,
14};
15use starweaver_stream::DisplayMessage;
16
17use crate::{
18    CliError, CliResult,
19    args::{
20        ApprovalCommand, ApprovalDecisionCommand, ApprovalListCommand, Cli, CliCommand,
21        DeferredCommand, DeferredCompleteCommand, DeferredFailCommand, DeferredListCommand,
22        OutputMode, ResumeCommand, RunCommand, SessionCommand, SessionSearchCommand,
23        SessionSearchGranularityArg, SessionSearchSourceArg, SessionSearchStatusArg, TuiCommand,
24    },
25    config::{
26        CliConfig, read_current_session, read_last_retention_maintenance, write_current_session,
27        write_last_retention_maintenance,
28    },
29    environment::{
30        ResolvedEnvironment, resolve_environment_for_session_with_attachments,
31        validate_environment_config,
32    },
33    local_store::LocalStore,
34    profiles::{ResolvedProfile, list_profiles, resolve_profile},
35    prompt_input::PromptInput,
36    runner::{
37        CliAgentExecutionHost, CliRunPolicy, CliSteeringMessage, execute_agent_session_with_host,
38        failed_display_message,
39    },
40    slash_commands::expand_slash_command,
41};
42
43mod auth;
44mod catalog;
45mod rendering;
46mod setup;
47mod tui;
48mod worktree;
49
50use auth::oauth_cli_error;
51use rendering::{
52    approval_status_name, render_agui_jsonl, render_approvals, render_completion, render_deferred,
53    render_deferred_decision, render_display_jsonl, render_display_text, render_prompt_run_json,
54    render_session_delete, render_session_search, render_session_show, render_sessions,
55    render_trim_report, session_value,
56};
57use setup::remove_file_if_exists;
58#[cfg(test)]
59use tui::model_choices;
60use worktree::apply_starweaver_run_metadata;
61
62pub(super) struct PromptRunExecution {
63    pub(super) session_id: String,
64    pub(super) run_id: String,
65    pub(super) status: String,
66    pub(super) output_mode: OutputMode,
67    pub(super) messages: Vec<DisplayMessage>,
68}
69
70pub(super) struct PreparedPromptRun {
71    pub(super) session_id: String,
72    pub(super) run_id: String,
73    pub(super) output_mode: OutputMode,
74    pub(super) run: RunRecord,
75    run_input: PromptInput,
76    resolved_profile: ResolvedProfile,
77    pub(super) environment: ResolvedEnvironment,
78    restore_state: Option<ResumableState>,
79    policy: CliRunPolicy,
80    execution_host: CliAgentExecutionHost,
81}
82
83impl PreparedPromptRun {
84    pub(super) fn set_execution_host(&mut self, execution_host: CliAgentExecutionHost) {
85        self.execution_host = execution_host;
86    }
87}
88
89pub(super) struct ExecutedPromptRun {
90    run: RunRecord,
91    output_mode: OutputMode,
92    execution: crate::runner::CliRunExecution,
93}
94
95const PROJECT_GUIDANCE_TAG: &str = "project-guidance";
96const USER_RULES_TAG: &str = "user-rules";
97
98fn search_query(command: SessionSearchCommand) -> SessionSearchQuery {
99    let session_statuses = command
100        .status
101        .map(|status| match status {
102            SessionSearchStatusArg::Active => SessionStatus::Active,
103            SessionSearchStatusArg::Archived => SessionStatus::Archived,
104            SessionSearchStatusArg::Failed => SessionStatus::Failed,
105        })
106        .into_iter()
107        .collect();
108    let sources = command
109        .sources
110        .into_iter()
111        .map(|source| match source {
112            SessionSearchSourceArg::SessionMetadata => SessionSearchSource::SessionMetadata,
113            SessionSearchSourceArg::RunInput => SessionSearchSource::RunInput,
114            SessionSearchSourceArg::RunOutputPreview => SessionSearchSource::RunOutputPreview,
115            SessionSearchSourceArg::DisplayMessage => SessionSearchSource::DisplayMessage,
116        })
117        .collect::<BTreeSet<_>>();
118    let granularity = match command.granularity {
119        SessionSearchGranularityArg::Session => SessionSearchGranularity::Session,
120        SessionSearchGranularityArg::Run => SessionSearchGranularity::Run,
121        SessionSearchGranularityArg::Occurrence => SessionSearchGranularity::Occurrence,
122    };
123    SessionSearchQuery {
124        text: command.text,
125        filter: SessionSearchFilter {
126            session_statuses,
127            profile: command.profile,
128            workspace: command.workspace,
129            ..SessionSearchFilter::default()
130        },
131        sources,
132        granularity,
133        limit: command.limit,
134        cursor: command.cursor,
135        ..SessionSearchQuery::default()
136    }
137}
138
139fn append_guidance_files(input: &mut PromptInput, config: &CliConfig) {
140    if let Some(project_guidance) = load_project_guidance(&config.workspace_root) {
141        input.push_guidance_text_part(project_guidance);
142    }
143    if let Some(user_rules) = load_user_rules(&config.global_dir) {
144        input.push_guidance_text_part(user_rules);
145    }
146}
147
148fn load_project_guidance(workspace_root: &Path) -> Option<String> {
149    let path = workspace_root.join("AGENTS.md");
150    let content = read_non_empty_utf8_file(&path)?;
151    Some(format!(
152        "<{PROJECT_GUIDANCE_TAG} name=AGENTS.md>\n{content}\n</{PROJECT_GUIDANCE_TAG}>"
153    ))
154}
155
156fn load_user_rules(global_dir: &Path) -> Option<String> {
157    let path = global_dir.join("RULES.md");
158    let content = read_non_empty_utf8_file(&path)?;
159    Some(format!(
160        "<{USER_RULES_TAG} location={}>\n{content}\n</{USER_RULES_TAG}>",
161        path_absolute_posix(&path)
162    ))
163}
164
165fn read_non_empty_utf8_file(path: &Path) -> Option<String> {
166    let content = fs::read_to_string(path).ok()?;
167    (!content.trim().is_empty()).then_some(content)
168}
169
170fn path_absolute_posix(path: &Path) -> String {
171    path.canonicalize()
172        .unwrap_or_else(|_| path.to_path_buf())
173        .display()
174        .to_string()
175        .replace('\\', "/")
176}
177
178#[allow(dead_code)]
179struct OAuthRefreshGuard {
180    stop_sender: mpsc::Sender<()>,
181    handle: Option<thread::JoinHandle<()>>,
182}
183
184impl Drop for OAuthRefreshGuard {
185    fn drop(&mut self) {
186        let _ = self.stop_sender.send(());
187        if let Some(handle) = self.handle.take() {
188            let _ = handle.join();
189        }
190    }
191}
192
193/// CLI service.
194pub struct CliService {
195    config: CliConfig,
196    store: Option<LocalStore>,
197}
198
199impl CliService {
200    /// Open service from resolved config.
201    pub const fn open(config: CliConfig) -> CliResult<Self> {
202        Ok(Self {
203            config,
204            store: None,
205        })
206    }
207
208    fn store(&mut self) -> CliResult<&mut LocalStore> {
209        if self.store.is_none() {
210            self.store = Some(LocalStore::open(&self.config)?);
211        }
212        self.store
213            .as_mut()
214            .ok_or_else(|| CliError::Storage("store initialization failed".to_string()))
215    }
216
217    /// Execute a parsed CLI command.
218    pub fn execute(mut self, cli: Cli) -> CliResult<String> {
219        if let Some(prompt) = cli.prompt.clone() {
220            let command = RunCommand {
221                prompt: Some(prompt),
222                prompt_parts: Vec::new(),
223                session: cli.session.clone(),
224                continue_session: cli.continue_session,
225                new_session: cli.new_session,
226                run: cli.run.clone(),
227                branch_from: cli.branch_from.clone(),
228                profile: cli.profile.clone(),
229                output: cli.output,
230                hitl: cli.hitl,
231                goal: None,
232                worker: cli.worker.clone(),
233                worker_label: cli.worker_label.clone(),
234                worktree: cli.worktree.clone(),
235                worktree_name: cli.worktree_name.clone(),
236                branch: cli.branch,
237                session_affinity_id: None,
238                environment_attachments: Vec::new(),
239            };
240            return self.run_prompt(&command);
241        }
242        let default_command = CliCommand::Tui(TuiCommand {
243            session: cli.session.clone(),
244            run: cli.run.clone(),
245            after: None,
246            interactive: false,
247            snapshot: false,
248            output: OutputMode::Text,
249            render_mode: None,
250        });
251        match cli.command.unwrap_or(default_command) {
252            CliCommand::Version => Ok(format!("{}\n", sdk_name())),
253            CliCommand::Diagnostics => Ok(self.diagnostics()?),
254            CliCommand::ReplayCheck => {
255                Ok("run `make replay-check` from the repository root\n".to_string())
256            }
257            CliCommand::Update(command) => Self::update(&command),
258            CliCommand::Run(command) => self.run_prompt(&command),
259            CliCommand::Session { command } => self.session(command),
260            CliCommand::Profile { command } => self.profile(command),
261            CliCommand::Setup(command) => self.setup(&command),
262            CliCommand::Auth { command } => Self::auth(command),
263            CliCommand::Skill { command } => self.skills(command),
264            CliCommand::Subagent { command } => self.subagents(command),
265            CliCommand::Mcp { command } => self.mcp(command),
266            CliCommand::Tools { command } => self.tools(&command),
267            CliCommand::Tui(command) => self.tui(&command),
268            CliCommand::Approval { command } => self.approval(command),
269            CliCommand::Deferred { command } => self.deferred(command),
270            CliCommand::Resume(command) => self.resume(&command),
271            CliCommand::Reset(command) => self.reset(&command),
272            CliCommand::Config { command } => self.config(command),
273            CliCommand::Completion { shell } => render_completion(shell),
274        }
275    }
276
277    pub(crate) fn run_prompt(&mut self, command: &RunCommand) -> CliResult<String> {
278        let execution = self.execute_prompt_run(command, None)?;
279        match execution.output_mode {
280            OutputMode::Text => Ok(render_display_text(&execution.messages)),
281            OutputMode::DisplayJsonl => render_display_jsonl(&execution.messages),
282            OutputMode::AguiJsonl => render_agui_jsonl(&execution.messages),
283            OutputMode::Json => render_prompt_run_json(&execution),
284            OutputMode::Silent => Ok(format!(
285                "session_id={}\nrun_id={}\nstatus={}\n",
286                execution.session_id, execution.run_id, execution.status
287            )),
288        }
289    }
290
291    fn execute_prompt_run(
292        &mut self,
293        command: &RunCommand,
294        stream_sender: Option<mpsc::Sender<AgentStreamRecord>>,
295    ) -> CliResult<PromptRunExecution> {
296        self.execute_prompt_run_with_channels(command, None, stream_sender, None, None)
297    }
298
299    #[allow(clippy::too_many_lines)]
300    fn execute_prompt_run_with_channels(
301        &mut self,
302        command: &RunCommand,
303        prompt_input: Option<PromptInput>,
304        stream_sender: Option<mpsc::Sender<AgentStreamRecord>>,
305        steering_receiver: Option<mpsc::Receiver<CliSteeringMessage>>,
306        cancel_receiver: Option<mpsc::Receiver<()>>,
307    ) -> CliResult<PromptRunExecution> {
308        let prepared = self.prepare_prompt_run(command, prompt_input)?;
309        let run_on_error = prepared.run.clone();
310        let executed = match Self::run_prepared_prompt(
311            prepared,
312            stream_sender,
313            steering_receiver,
314            cancel_receiver,
315        ) {
316            Ok(executed) => executed,
317            Err(error) => {
318                self.fail_prepared_prompt_run(run_on_error, &error)?;
319                return Err(error);
320            }
321        };
322        self.complete_prompt_run(executed)
323    }
324
325    #[allow(clippy::too_many_lines)]
326    pub(super) fn prepare_prompt_run(
327        &mut self,
328        command: &RunCommand,
329        prompt_input: Option<PromptInput>,
330    ) -> CliResult<PreparedPromptRun> {
331        let input =
332            prompt_input.map_or_else(|| command.prompt_text().map(PromptInput::text), Ok)?;
333        let raw_prompt = input.text.clone();
334        let slash_expansion = expand_slash_command(&self.config.slash_commands, &raw_prompt);
335        let prompt = slash_expansion
336            .as_ref()
337            .map_or(raw_prompt, |expanded| expanded.prompt.clone());
338        let mut run_input = PromptInput {
339            text: prompt.clone(),
340            attachments: input.attachments,
341            extra_text_parts: input.extra_text_parts,
342            guidance_text_parts: input.guidance_text_parts,
343        };
344        let worktree = self.resolve_worktree(command)?;
345        let selected_profile = command
346            .profile
347            .as_deref()
348            .unwrap_or(&self.config.default_profile);
349        let resolved_profile = resolve_profile(&self.config, Some(selected_profile))?;
350        let mut run_config = self.config.clone();
351        if let Some(worktree) = worktree.as_ref() {
352            run_config.workspace_root.clone_from(&worktree.path);
353        }
354        validate_environment_config(&run_config)?;
355        append_guidance_files(&mut run_input, &run_config);
356        let (session_id, created) = self.resolve_session(command, &resolved_profile.name)?;
357        let environment = resolve_environment_for_session_with_attachments(
358            &run_config,
359            &session_id,
360            &command.environment_attachments,
361        )?;
362        let mut restore_from = command.run.clone().or_else(|| command.branch_from.clone());
363        if restore_from.is_none() && !created {
364            restore_from = self
365                .store()?
366                .load_session(&session_id)
367                .ok()
368                .and_then(|session| {
369                    session
370                        .active_run_id
371                        .or(session.head_run_id)
372                        .or(session.head_success_run_id)
373                        .map(|run| run.as_str().to_string())
374                });
375        }
376        let restore_state = self
377            .store()?
378            .load_restore_state(&session_id, restore_from.as_deref())?;
379        let mut run =
380            self.store()?
381                .append_run(&session_id, prompt, restore_from, &resolved_profile.name)?;
382        apply_starweaver_run_metadata(
383            &mut run,
384            command,
385            worktree.as_ref(),
386            slash_expansion.as_ref(),
387        );
388        if let Some(session_affinity_id) = command.session_affinity_id.as_deref() {
389            run.metadata.insert(
390                "starweaver.session_affinity_id".to_string(),
391                json!(session_affinity_id),
392            );
393        }
394        if !command.environment_attachments.is_empty() {
395            run.metadata.insert(
396                "starweaver.environment_attachments".to_string(),
397                json!(command.environment_attachments),
398            );
399            run.metadata.insert(
400                "starweaver.environment_attachment_ids".to_string(),
401                json!(
402                    command
403                        .environment_attachments
404                        .iter()
405                        .map(|attachment| attachment.id.as_str())
406                        .collect::<Vec<_>>()
407                ),
408            );
409        }
410        write_current_session(&self.config, &session_id)?;
411        let hitl = command.hitl.unwrap_or(self.config.default_hitl);
412        let goal = command
413            .goal
414            .as_ref()
415            .map(|goal| crate::runner::CliGoalRunPolicy {
416                objective: goal.objective.clone(),
417                max_iterations: goal.max_iterations.max(1),
418            });
419        let output_mode = command.output.unwrap_or(self.config.default_output);
420        Ok(PreparedPromptRun {
421            session_id,
422            run_id: run.run_id.as_str().to_string(),
423            output_mode,
424            run,
425            run_input,
426            resolved_profile,
427            environment,
428            restore_state,
429            policy: CliRunPolicy { hitl, goal },
430            execution_host: if command.worker.is_some() || command.worker_label.is_some() {
431                CliAgentExecutionHost::disabled()
432            } else {
433                CliAgentExecutionHost::blocking()
434            },
435        })
436    }
437
438    pub(super) fn run_prepared_prompt(
439        prepared: PreparedPromptRun,
440        stream_sender: Option<mpsc::Sender<AgentStreamRecord>>,
441        steering_receiver: Option<mpsc::Receiver<CliSteeringMessage>>,
442        cancel_receiver: Option<mpsc::Receiver<()>>,
443    ) -> CliResult<ExecutedPromptRun> {
444        let PreparedPromptRun {
445            output_mode,
446            run,
447            run_input,
448            resolved_profile,
449            environment,
450            restore_state,
451            policy,
452            execution_host,
453            ..
454        } = prepared;
455        let result = execute_agent_session_with_host(
456            run_input,
457            &run,
458            &resolved_profile,
459            &environment.provider,
460            environment.process_provider.as_ref(),
461            restore_state,
462            &policy,
463            stream_sender,
464            steering_receiver,
465            cancel_receiver,
466            execution_host,
467        );
468        result.map(|execution| ExecutedPromptRun {
469            run,
470            output_mode,
471            execution,
472        })
473    }
474
475    pub(super) fn fail_prepared_prompt_run(
476        &mut self,
477        mut run: RunRecord,
478        error: &CliError,
479    ) -> CliResult<()> {
480        let messages = failed_display_message(&run, &error.to_string());
481        self.store()?
482            .fail_run_with_messages(&mut run, error.to_string(), &messages)
483    }
484
485    pub(super) fn complete_prompt_run(
486        &mut self,
487        executed: ExecutedPromptRun,
488    ) -> CliResult<PromptRunExecution> {
489        let ExecutedPromptRun {
490            mut run,
491            output_mode,
492            execution,
493        } = executed;
494        let execution_failed = execution.artifacts.status == RunStatus::Failed;
495        let output = execution.output;
496        let messages = self
497            .store()?
498            .complete_run(&mut run, output.clone(), execution.artifacts)?;
499        if execution_failed && matches!(output_mode, OutputMode::Text | OutputMode::Silent) {
500            return Err(CliError::Run(output));
501        }
502        self.run_retention_maintenance(run.session_id.as_str())?;
503        Ok(PromptRunExecution {
504            session_id: run.session_id.as_str().to_string(),
505            run_id: run.run_id.as_str().to_string(),
506            status: run_status_name(run.status).to_string(),
507            output_mode,
508            messages,
509        })
510    }
511
512    fn run_retention_maintenance(&mut self, current_session_id: &str) -> CliResult<()> {
513        if !self.config.auto_trim {
514            return Ok(());
515        }
516        let current_keep_runs = self.config.current_session_keep_recent_runs;
517        self.store()?.trim(
518            vec![current_session_id.to_string()],
519            current_keep_runs,
520            false,
521        )?;
522        if !self.should_run_all_sessions_retention()? {
523            return Ok(());
524        }
525        let sessions = self.store()?.all_session_ids()?;
526        let all_sessions_keep_runs = self.config.all_sessions_keep_recent_runs;
527        let older_than = chrono::Duration::days(
528            i64::try_from(self.config.all_sessions_keep_days)
529                .unwrap_or(i64::MAX)
530                .max(0),
531        );
532        self.store()?
533            .trim_with_age(sessions, all_sessions_keep_runs, Some(older_than), false)?;
534        write_last_retention_maintenance(&self.config, chrono::Utc::now())?;
535        Ok(())
536    }
537
538    fn should_run_all_sessions_retention(&self) -> CliResult<bool> {
539        if self.config.all_sessions_keep_days == 0 || self.config.all_sessions_interval_hours == 0 {
540            return Ok(false);
541        }
542        let Some(last_run) = read_last_retention_maintenance(&self.config)? else {
543            return Ok(true);
544        };
545        let elapsed = chrono::Utc::now().signed_duration_since(last_run);
546        Ok(elapsed
547            >= chrono::Duration::hours(
548                i64::try_from(self.config.all_sessions_interval_hours)
549                    .unwrap_or(i64::MAX)
550                    .max(0),
551            ))
552    }
553
554    fn resolve_session(
555        &mut self,
556        command: &RunCommand,
557        profile: &str,
558    ) -> CliResult<(String, bool)> {
559        if command.new_session {
560            let session = self
561                .store()?
562                .create_session(profile, Some("CLI session".to_string()))?;
563            return Ok((session.session_id.as_str().to_string(), true));
564        }
565        if let Some(session_id) = command.session.as_ref() {
566            self.store()?.load_session(session_id)?;
567            return Ok((session_id.clone(), false));
568        }
569        if command.continue_session {
570            if let Some(session_id) = read_current_session(&self.config)?
571                && self.store()?.load_session(&session_id).is_ok()
572            {
573                return Ok((session_id, false));
574            }
575            if let Some(session) = self.store()?.latest_session()? {
576                return Ok((session.session_id.as_str().to_string(), false));
577            }
578        }
579        let session = self
580            .store()?
581            .create_session(profile, Some("CLI session".to_string()))?;
582        Ok((session.session_id.as_str().to_string(), true))
583    }
584
585    fn session(&mut self, command: SessionCommand) -> CliResult<String> {
586        match command {
587            SessionCommand::List(command) => {
588                let sessions = self.store()?.list_sessions(command.limit)?;
589                render_sessions(&sessions, command.output)
590            }
591            SessionCommand::Search(command) => {
592                let output = command.output;
593                let page = self.store()?.search_sessions(search_query(command))?;
594                render_session_search(&page, output)
595            }
596            SessionCommand::Show(command) => {
597                let session = self.store()?.load_session(&command.session_id)?;
598                let runs = self.store()?.list_runs(&command.session_id, command.runs)?;
599                let value = session_value(&session);
600                render_session_show(&value, &runs, command.output)
601            }
602            SessionCommand::Replay(command) => {
603                let messages = self.store()?.replay_display(
604                    &command.session_id,
605                    command.run.as_deref(),
606                    command.after,
607                )?;
608                match command.output {
609                    OutputMode::Text => Ok(render_display_text(&messages)),
610                    OutputMode::DisplayJsonl => render_display_jsonl(&messages),
611                    OutputMode::AguiJsonl => render_agui_jsonl(&messages),
612                    OutputMode::Json => Ok(format!(
613                        "{}\n",
614                        serde_json::to_string(&json!({
615                            "sessionId": command.session_id,
616                            "runId": command.run,
617                            "messages": messages,
618                            "status": "replayed"
619                        }))?
620                    )),
621                    OutputMode::Silent => Ok(format!(
622                        "session_id={}\nmessages={}\nstatus=replayed\n",
623                        command.session_id,
624                        messages.len()
625                    )),
626                }
627            }
628            SessionCommand::Delete(command) => {
629                if !command.yes {
630                    return Err(CliError::Usage(
631                        "pass --yes to delete a local session".to_string(),
632                    ));
633                }
634                let session_id = self.store()?.resolve_session_prefix(&command.session_id)?;
635                let deleted = self.store()?.delete_session(&session_id)?;
636                if read_current_session(&self.config)?.as_deref() == Some(session_id.as_str()) {
637                    let _removed =
638                        remove_file_if_exists(&self.config.project_dir.join("state.json"))?;
639                }
640                render_session_delete(&session_id, deleted, command.output)
641            }
642            SessionCommand::Trim(command) => {
643                let sessions = if command.all {
644                    self.store()?.all_session_ids()?
645                } else if let Some(session_id) = command.session {
646                    vec![session_id]
647                } else {
648                    read_current_session(&self.config)?.into_iter().collect()
649                };
650                let older_than = command
651                    .older_than
652                    .as_deref()
653                    .map(parse_duration)
654                    .transpose()?;
655                let report = self.store()?.trim_with_age(
656                    sessions,
657                    command.keep_runs,
658                    older_than,
659                    command.dry_run,
660                )?;
661                render_trim_report(&report, command.output)
662            }
663        }
664    }
665
666    fn approval(&mut self, command: ApprovalCommand) -> CliResult<String> {
667        match command {
668            ApprovalCommand::List(command) => self.approval_list(&command),
669            ApprovalCommand::Show { approval_id } => {
670                let approval = self.store()?.load_approval(&approval_id)?;
671                Ok(format!("{}\n", serde_json::to_string(&approval)?))
672            }
673            ApprovalCommand::Approve(command) => {
674                self.approval_decision(&command, ApprovalStatus::Approved)
675            }
676            ApprovalCommand::Reject(command) => {
677                self.approval_decision(&command, ApprovalStatus::Denied)
678            }
679        }
680    }
681
682    fn approval_list(&mut self, command: &ApprovalListCommand) -> CliResult<String> {
683        let approvals = self
684            .store()?
685            .list_approvals(command.session.as_deref(), command.run.as_deref())?;
686        render_approvals(&approvals, command.output)
687    }
688
689    fn approval_decision(
690        &mut self,
691        command: &ApprovalDecisionCommand,
692        status: ApprovalStatus,
693    ) -> CliResult<String> {
694        let approval =
695            self.store()?
696                .decide_approval(&command.approval_id, status, command.reason.clone())?;
697        match command.output {
698            OutputMode::Text => Ok(format!(
699                "approval_id={}\nstatus={}\nrun_id={}\n",
700                approval.approval_id,
701                approval_status_name(approval.status),
702                approval.run_id.as_str()
703            )),
704            OutputMode::DisplayJsonl | OutputMode::AguiJsonl | OutputMode::Json => {
705                Ok(format!("{}\n", serde_json::to_string(&approval)?))
706            }
707            OutputMode::Silent => Ok(format!(
708                "approval_id={}\nstatus={}\n",
709                approval.approval_id,
710                approval_status_name(approval.status)
711            )),
712        }
713    }
714
715    fn deferred(&mut self, command: DeferredCommand) -> CliResult<String> {
716        match command {
717            DeferredCommand::List(command) => self.deferred_list(&command),
718            DeferredCommand::Show { deferred_id } => {
719                let deferred = self.store()?.load_deferred_tool(&deferred_id)?;
720                Ok(format!("{}\n", serde_json::to_string(&deferred)?))
721            }
722            DeferredCommand::Complete(command) => self.deferred_complete(&command),
723            DeferredCommand::Fail(command) => self.deferred_fail(&command),
724        }
725    }
726
727    fn deferred_list(&mut self, command: &DeferredListCommand) -> CliResult<String> {
728        let records = self
729            .store()?
730            .list_deferred_tools(command.session.as_deref(), command.run.as_deref())?;
731        render_deferred(&records, command.output)
732    }
733
734    fn deferred_complete(&mut self, command: &DeferredCompleteCommand) -> CliResult<String> {
735        let value = serde_json::from_str::<Value>(&command.result)
736            .map_err(|error| CliError::Usage(format!("invalid deferred result JSON: {error}")))?;
737        let record = self
738            .store()?
739            .complete_deferred_tool(&command.deferred_id, value)?;
740        render_deferred_decision(&record, command.output)
741    }
742
743    fn deferred_fail(&mut self, command: &DeferredFailCommand) -> CliResult<String> {
744        let record = self
745            .store()?
746            .fail_deferred_tool(&command.deferred_id, &command.error)?;
747        render_deferred_decision(&record, command.output)
748    }
749
750    fn resume(&mut self, command: &ResumeCommand) -> CliResult<String> {
751        let session_id = self.resolve_session_id(command.session.as_deref())?;
752        let source_run = self.resolve_resume_run(&session_id, command.run.as_deref())?;
753        let run_command = RunCommand {
754            prompt: Some(format!(
755                "{}\n\nResuming from run {} with any persisted approval and deferred-tool decisions.",
756                command.prompt,
757                source_run.run_id.as_str()
758            )),
759            prompt_parts: Vec::new(),
760            session: Some(session_id),
761            continue_session: false,
762            new_session: false,
763            run: Some(source_run.run_id.as_str().to_string()),
764            branch_from: None,
765            profile: source_run.profile.clone(),
766            output: command.output,
767            hitl: command.hitl,
768            goal: None,
769            worker: None,
770            worker_label: None,
771            worktree: None,
772            worktree_name: None,
773            branch: None,
774            session_affinity_id: None,
775            environment_attachments: Vec::new(),
776        };
777        self.run_prompt(&run_command)
778    }
779
780    fn resolve_session_id(&mut self, requested: Option<&str>) -> CliResult<String> {
781        if let Some(session_id) = requested {
782            self.store()?.load_session(session_id)?;
783            return Ok(session_id.to_string());
784        }
785        if let Some(session_id) = read_current_session(&self.config)?
786            && self.store()?.load_session(&session_id).is_ok()
787        {
788            return Ok(session_id);
789        }
790        self.store()?
791            .latest_session()?
792            .map(|session| session.session_id.as_str().to_string())
793            .ok_or_else(|| CliError::NotFound("session".to_string()))
794    }
795
796    fn resolve_resume_run(
797        &mut self,
798        session_id: &str,
799        requested: Option<&str>,
800    ) -> CliResult<starweaver_session::RunRecord> {
801        if let Some(run_id) = requested {
802            return self.store()?.load_run(session_id, run_id);
803        }
804        let session = self.store()?.load_session(session_id)?;
805        let run_id = session
806            .active_run_id
807            .as_ref()
808            .or(session.head_run_id.as_ref())
809            .ok_or_else(|| CliError::NotFound("run".to_string()))?;
810        self.store()?.load_run(session_id, run_id.as_str())
811    }
812}
813
814#[allow(dead_code)]
815fn start_oauth_refresh_guard(config: &CliConfig) -> CliResult<Option<OAuthRefreshGuard>> {
816    if !config.oauth_refresh.enabled {
817        return Ok(None);
818    }
819    let models = list_profiles(config)
820        .into_iter()
821        .map(|profile| profile.model_id)
822        .collect::<Vec<_>>();
823    if config.oauth_refresh.interval_seconds == 0 {
824        return Err(CliError::Usage(
825            "invalid oauth_refresh.interval_seconds: value must be positive".to_string(),
826        ));
827    }
828    if config.oauth_refresh.failure_retry_seconds == 0 {
829        return Err(CliError::Usage(
830            "invalid oauth_refresh.failure_retry_seconds: value must be positive".to_string(),
831        ));
832    }
833    let mut supervisor = create_oauth_refresh_supervisor_for_models_with_options(
834        models.iter().map(String::as_str),
835        Duration::from_secs(config.oauth_refresh.interval_seconds),
836        Duration::from_secs(config.oauth_refresh.failure_retry_seconds),
837        config.oauth_refresh.refresh_on_startup,
838    )
839    .map_err(oauth_cli_error)?;
840    let Some(mut supervisor) = supervisor.take() else {
841        return Ok(None);
842    };
843    let (stop_sender, stop_receiver) = mpsc::channel::<()>();
844    let handle = thread::Builder::new()
845        .name("starweaver-oauth-refresh".to_string())
846        .spawn(move || {
847            let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
848                .enable_all()
849                .build()
850            else {
851                return;
852            };
853            runtime.block_on(async move {
854                supervisor.start().await;
855                let _ = tokio::task::spawn_blocking(move || stop_receiver.recv()).await;
856                supervisor.shutdown().await;
857            });
858        })
859        .map_err(|error| CliError::Run(error.to_string()))?;
860    Ok(Some(OAuthRefreshGuard {
861        stop_sender,
862        handle: Some(handle),
863    }))
864}
865
866fn render_json_lines<T: serde::Serialize>(items: &[T]) -> CliResult<String> {
867    items
868        .iter()
869        .map(|item| serde_json::to_string(item).map(|line| format!("{line}\n")))
870        .collect::<Result<String, _>>()
871        .map_err(CliError::from)
872}
873
874const fn run_status_name(status: starweaver_session::RunStatus) -> &'static str {
875    status.as_str()
876}
877
878fn parse_duration(value: &str) -> CliResult<chrono::Duration> {
879    let trimmed = value.trim();
880    if trimmed.is_empty() {
881        return Err(CliError::Usage("duration cannot be empty".to_string()));
882    }
883    let (number, unit) = trimmed.split_at(
884        trimmed
885            .find(|ch: char| !ch.is_ascii_digit())
886            .unwrap_or(trimmed.len()),
887    );
888    let amount = number
889        .parse::<i64>()
890        .map_err(|error| CliError::Usage(error.to_string()))?;
891    let duration = match unit {
892        "" | "s" | "sec" | "secs" => chrono::Duration::seconds(amount),
893        "m" | "min" | "mins" => chrono::Duration::minutes(amount),
894        "h" | "hr" | "hrs" => chrono::Duration::hours(amount),
895        "d" | "day" | "days" => chrono::Duration::days(amount),
896        other => return Err(CliError::Usage(format!("unknown duration unit: {other}"))),
897    };
898    Ok(duration)
899}
900
901#[cfg(test)]
902mod tests;