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