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