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 chrono::Utc;
7use serde_json::{Value, json};
8use starweaver_agent::ResumableState;
9use starweaver_core::{RunId, SessionId, sdk_name};
10use starweaver_oauth_provider::create_oauth_refresh_supervisor_for_models_with_options;
11use starweaver_runtime::AgentStreamRecord;
12use starweaver_session::{
13    ApprovalStatus, HitlResumeClaim, RunRecord, RunStatus, SessionSearchFilter,
14    SessionSearchGranularity, SessionSearchQuery, SessionSearchSource, SessionStatus, SessionStore,
15};
16use starweaver_stream::DisplayMessage;
17
18use crate::{
19    CliError, CliResult,
20    args::{
21        ApprovalCommand, ApprovalDecisionCommand, ApprovalListCommand, Cli, CliCommand,
22        DeferredCommand, DeferredCompleteCommand, DeferredFailCommand, DeferredListCommand,
23        OutputMode, ResumeCommand, RunCommand, SessionCommand, SessionSearchCommand,
24        SessionSearchGranularityArg, SessionSearchSourceArg, SessionSearchStatusArg, TuiCommand,
25    },
26    config::{
27        CliConfig, read_current_session, read_last_retention_maintenance, write_current_session,
28        write_last_retention_maintenance,
29    },
30    environment::{
31        ResolvedEnvironment, resolve_environment_for_session_with_attachments,
32        validate_environment_config,
33    },
34    local_store::{
35        HITL_RESUME_CLAIM_ID_METADATA_KEY, HITL_RESUME_PREFLIGHT_SOURCE_RUN_ID_METADATA_KEY,
36        HITL_RESUME_SOURCE_RUN_ID_METADATA_KEY, LocalSessionStore, LocalStore,
37    },
38    profiles::{ResolvedProfile, list_profiles, resolve_profile},
39    prompt_input::PromptInput,
40    runner::{
41        CliAgentExecutionHost, CliRunPolicy, CliSteeringChannel, execute_agent_session_with_host,
42        failed_display_message,
43    },
44    slash_commands::{ExpandedExplicitSkills, expand_explicit_skills, expand_slash_command},
45};
46
47mod auth;
48mod catalog;
49mod rendering;
50mod setup;
51mod tui;
52mod worktree;
53
54use auth::oauth_cli_error;
55use rendering::{
56    approval_status_name, render_agui_jsonl, render_approvals, render_completion, render_deferred,
57    render_deferred_decision, render_display_jsonl, render_display_text, render_prompt_run_json,
58    render_session_delete, render_session_search, render_session_show, render_sessions,
59    render_trim_report, session_value,
60};
61use setup::remove_file_if_exists;
62#[cfg(test)]
63use tui::model_choices;
64use worktree::apply_starweaver_run_metadata;
65
66pub(super) struct PromptRunExecution {
67    pub(super) session_id: String,
68    pub(super) run_id: String,
69    pub(super) status: String,
70    pub(super) output_mode: OutputMode,
71    pub(super) messages: Vec<DisplayMessage>,
72}
73
74pub(super) struct PreparedPromptRun {
75    pub(super) session_id: String,
76    pub(super) run_id: String,
77    pub(super) output_mode: OutputMode,
78    pub(super) run: RunRecord,
79    run_input: PromptInput,
80    resolved_profile: ResolvedProfile,
81    pub(super) environment: ResolvedEnvironment,
82    restore_state: Option<ResumableState>,
83    policy: CliRunPolicy,
84    execution_host: CliAgentExecutionHost,
85    hitl_resume_claim: Option<HitlResumeClaim>,
86}
87
88impl PreparedPromptRun {
89    pub(super) fn set_execution_host(&mut self, execution_host: CliAgentExecutionHost) {
90        self.execution_host = execution_host;
91    }
92}
93
94pub(super) struct ExecutedPromptRun {
95    run: RunRecord,
96    output_mode: OutputMode,
97    execution: crate::runner::CliRunExecution,
98}
99
100enum HitlResumeClaimOperation {
101    Claim(HitlResumeClaim),
102    Start {
103        session_id: SessionId,
104        run_id: RunId,
105        claim_id: String,
106    },
107    Release {
108        session_id: SessionId,
109        run_id: RunId,
110        claim_id: String,
111    },
112}
113
114const PROJECT_GUIDANCE_TAG: &str = "project-guidance";
115const USER_RULES_TAG: &str = "user-rules";
116
117fn restore_requires_hitl_claim(status: RunStatus, hitl_resume: bool, branch_from: bool) -> bool {
118    status == RunStatus::Waiting && !hitl_resume && !branch_from
119}
120
121fn search_query(command: SessionSearchCommand) -> SessionSearchQuery {
122    let session_statuses = command
123        .status
124        .map(|status| match status {
125            SessionSearchStatusArg::Active => SessionStatus::Active,
126            SessionSearchStatusArg::Archived => SessionStatus::Archived,
127            SessionSearchStatusArg::Failed => SessionStatus::Failed,
128        })
129        .into_iter()
130        .collect();
131    let sources = command
132        .sources
133        .into_iter()
134        .map(|source| match source {
135            SessionSearchSourceArg::SessionMetadata => SessionSearchSource::SessionMetadata,
136            SessionSearchSourceArg::RunInput => SessionSearchSource::RunInput,
137            SessionSearchSourceArg::RunOutputPreview => SessionSearchSource::RunOutputPreview,
138            SessionSearchSourceArg::DisplayMessage => SessionSearchSource::DisplayMessage,
139        })
140        .collect::<BTreeSet<_>>();
141    let granularity = match command.granularity {
142        SessionSearchGranularityArg::Session => SessionSearchGranularity::Session,
143        SessionSearchGranularityArg::Run => SessionSearchGranularity::Run,
144        SessionSearchGranularityArg::Occurrence => SessionSearchGranularity::Occurrence,
145    };
146    SessionSearchQuery {
147        text: command.text,
148        filter: SessionSearchFilter {
149            session_statuses,
150            profile: command.profile,
151            workspace: command.workspace,
152            ..SessionSearchFilter::default()
153        },
154        sources,
155        granularity,
156        limit: command.limit,
157        cursor: command.cursor,
158        ..SessionSearchQuery::default()
159    }
160}
161
162fn append_guidance_files(input: &mut PromptInput, config: &CliConfig) {
163    if let Some(project_guidance) = load_project_guidance(&config.workspace_root) {
164        input.push_guidance_text_part(project_guidance);
165    }
166    if let Some(user_rules) = load_user_rules(&config.global_dir) {
167        input.push_guidance_text_part(user_rules);
168    }
169}
170
171fn append_explicit_skill_guidance(
172    input: &mut PromptInput,
173    explicit_skills: Option<&ExpandedExplicitSkills>,
174) {
175    let Some(explicit_skills) = explicit_skills else {
176        return;
177    };
178    let names = explicit_skills
179        .skills
180        .iter()
181        .map(|skill| skill.package.name.as_str())
182        .collect::<Vec<_>>()
183        .join(", ");
184    input.push_guidance_text_part(format!(
185        "<explicit-skill-composition>\nThe user explicitly activated these skills in priority order: {names}. Apply all compatible instructions. Treat the first skill as the primary workflow and later skills as supporting workflows. The current user request overrides skill defaults. If selected skills conflict irreconcilably, ask the user for clarification.\n</explicit-skill-composition>"
186    ));
187    for skill in &explicit_skills.skills {
188        let package = &skill.package;
189        let Some(body) = package.body.as_deref() else {
190            continue;
191        };
192        input.push_guidance_text_part(format!(
193            "<explicitly-activated-skill>\n<name>{}</name>\n<path>{}</path>\n<instructions>\n{}\n</instructions>\n</explicitly-activated-skill>",
194            escape_xml_text(&package.name),
195            escape_xml_text(&package.path),
196            body
197        ));
198    }
199}
200
201fn escape_xml_text(value: &str) -> String {
202    value
203        .replace('&', "&amp;")
204        .replace('<', "&lt;")
205        .replace('>', "&gt;")
206}
207
208fn load_project_guidance(workspace_root: &Path) -> Option<String> {
209    let path = workspace_root.join("AGENTS.md");
210    let content = read_non_empty_utf8_file(&path)?;
211    Some(format!(
212        "<{PROJECT_GUIDANCE_TAG} name=AGENTS.md>\n{content}\n</{PROJECT_GUIDANCE_TAG}>"
213    ))
214}
215
216fn load_user_rules(global_dir: &Path) -> Option<String> {
217    let path = global_dir.join("RULES.md");
218    let content = read_non_empty_utf8_file(&path)?;
219    Some(format!(
220        "<{USER_RULES_TAG} location={}>\n{content}\n</{USER_RULES_TAG}>",
221        path_absolute_posix(&path)
222    ))
223}
224
225fn read_non_empty_utf8_file(path: &Path) -> Option<String> {
226    let content = fs::read_to_string(path).ok()?;
227    (!content.trim().is_empty()).then_some(content)
228}
229
230fn path_absolute_posix(path: &Path) -> String {
231    path.canonicalize()
232        .unwrap_or_else(|_| path.to_path_buf())
233        .display()
234        .to_string()
235        .replace('\\', "/")
236}
237
238#[allow(dead_code)]
239struct OAuthRefreshGuard {
240    stop_sender: mpsc::Sender<()>,
241    handle: Option<thread::JoinHandle<()>>,
242}
243
244impl Drop for OAuthRefreshGuard {
245    fn drop(&mut self) {
246        let _ = self.stop_sender.send(());
247        if let Some(handle) = self.handle.take() {
248            let _ = handle.join();
249        }
250    }
251}
252
253/// CLI service.
254pub struct CliService {
255    config: CliConfig,
256    store: Option<LocalStore>,
257}
258
259impl CliService {
260    /// Open service from resolved config.
261    pub const fn open(config: CliConfig) -> CliResult<Self> {
262        Ok(Self {
263            config,
264            store: None,
265        })
266    }
267
268    fn store(&mut self) -> CliResult<&mut LocalStore> {
269        if self.store.is_none() {
270            self.store = Some(LocalStore::open(&self.config)?);
271        }
272        self.store
273            .as_mut()
274            .ok_or_else(|| CliError::Storage("store initialization failed".to_string()))
275    }
276
277    /// Execute a parsed CLI command.
278    pub fn execute(mut self, cli: Cli) -> CliResult<String> {
279        if let Some(prompt) = cli.prompt.clone() {
280            let command = RunCommand {
281                prompt: Some(prompt),
282                prompt_parts: Vec::new(),
283                session: cli.session.clone(),
284                continue_session: cli.continue_session,
285                new_session: cli.new_session,
286                run: cli.run.clone(),
287                branch_from: cli.branch_from.clone(),
288                profile: cli.profile.clone(),
289                output: cli.output,
290                hitl: cli.hitl,
291                goal: None,
292                worker: cli.worker.clone(),
293                worker_label: cli.worker_label.clone(),
294                worktree: cli.worktree.clone(),
295                worktree_name: cli.worktree_name.clone(),
296                branch: cli.branch,
297                session_affinity_id: None,
298                environment_attachments: Vec::new(),
299                hitl_resume: false,
300            };
301            return self.run_prompt(&command);
302        }
303        let default_command = CliCommand::Tui(TuiCommand {
304            session: cli.session.clone(),
305            run: cli.run.clone(),
306            after: None,
307            interactive: false,
308            snapshot: false,
309            output: OutputMode::Text,
310            render_mode: None,
311        });
312        match cli.command.unwrap_or(default_command) {
313            CliCommand::Version => Ok(format!("{}\n", sdk_name())),
314            CliCommand::Diagnostics => Ok(self.diagnostics()?),
315            CliCommand::ReplayCheck => {
316                Ok("run `make replay-check` from the repository root\n".to_string())
317            }
318            CliCommand::Update(command) => Self::update(&command),
319            CliCommand::Run(command) => self.run_prompt(&command),
320            CliCommand::Session { command } => self.session(command),
321            CliCommand::Profile { command } => self.profile(command),
322            CliCommand::Setup(command) => self.setup(&command),
323            CliCommand::Auth { command } => Self::auth(command),
324            CliCommand::Skill { command } => self.skills(command),
325            CliCommand::Subagent { command } => self.subagents(command),
326            CliCommand::Mcp { command } => self.mcp(command),
327            CliCommand::Tools { command } => self.tools(&command),
328            CliCommand::Tui(command) => self.tui(&command),
329            CliCommand::Approval { command } => self.approval(command),
330            CliCommand::Deferred { command } => self.deferred(command),
331            CliCommand::Resume(command) => self.resume(&command),
332            CliCommand::Reset(command) => self.reset(&command),
333            CliCommand::Config { command } => self.config(command),
334            CliCommand::Completion { shell } => render_completion(shell),
335        }
336    }
337
338    pub(crate) fn run_prompt(&mut self, command: &RunCommand) -> CliResult<String> {
339        let execution = self.execute_prompt_run(command, None)?;
340        match execution.output_mode {
341            OutputMode::Text => Ok(render_display_text(&execution.messages)),
342            OutputMode::DisplayJsonl => render_display_jsonl(&execution.messages),
343            OutputMode::AguiJsonl => render_agui_jsonl(&execution.messages),
344            OutputMode::Json => render_prompt_run_json(&execution),
345            OutputMode::Silent => Ok(format!(
346                "session_id={}\nrun_id={}\nstatus={}\n",
347                execution.session_id, execution.run_id, execution.status
348            )),
349        }
350    }
351
352    fn execute_prompt_run(
353        &mut self,
354        command: &RunCommand,
355        stream_sender: Option<mpsc::SyncSender<AgentStreamRecord>>,
356    ) -> CliResult<PromptRunExecution> {
357        self.execute_prompt_run_with_channels(command, None, stream_sender, None, None)
358    }
359
360    #[allow(clippy::too_many_lines)]
361    fn execute_prompt_run_with_channels(
362        &mut self,
363        command: &RunCommand,
364        prompt_input: Option<PromptInput>,
365        stream_sender: Option<mpsc::SyncSender<AgentStreamRecord>>,
366        steering_channel: Option<CliSteeringChannel>,
367        cancel_receiver: Option<mpsc::Receiver<()>>,
368    ) -> CliResult<PromptRunExecution> {
369        let mut prepared = self.prepare_prompt_run(command, prompt_input)?;
370        let run_on_error = prepared.run.clone();
371        if let Err(error) = self.start_prepared_hitl_resume(&mut prepared) {
372            self.fail_prepared_prompt_run(run_on_error, &error)?;
373            return Err(error);
374        }
375        let run_on_error = prepared.run.clone();
376        let executed = match Self::run_prepared_prompt(
377            prepared,
378            stream_sender,
379            steering_channel,
380            cancel_receiver,
381        ) {
382            Ok(executed) => executed,
383            Err(error) => {
384                self.fail_prepared_prompt_run(run_on_error, &error)?;
385                return Err(error);
386            }
387        };
388        self.complete_prompt_run(executed)
389    }
390
391    fn reject_ordinary_admission_during_waiting_continuation(
392        &mut self,
393        session_id: &str,
394        hitl_resume: bool,
395    ) -> CliResult<()> {
396        if hitl_resume {
397            return Ok(());
398        }
399        let session = self.store()?.load_session(session_id)?;
400        let runs = self.store()?.list_run_records(session_id)?;
401        let active_run_id = session.active_run_id.as_ref();
402        for run in runs.iter().filter(|run| {
403            active_run_id == Some(&run.run_id)
404                || (run.status.is_active() && run.status != RunStatus::Waiting)
405        }) {
406            let waiting_source_run_id = if run.status == RunStatus::Waiting {
407                Some(&run.run_id)
408            } else {
409                run.restore_from_run_id.as_ref().filter(|source_run_id| {
410                    runs.iter().any(|source| {
411                        source.run_id.as_str() == source_run_id.as_str()
412                            && source.status == RunStatus::Waiting
413                    })
414                })
415            };
416            if let Some(waiting_source_run_id) = waiting_source_run_id {
417                return Err(CliError::Run(format!(
418                    "run {} is continuing waiting run {}; wait for it to finish before starting another prompt",
419                    run.run_id.as_str(),
420                    waiting_source_run_id.as_str()
421                )));
422            }
423        }
424        Ok(())
425    }
426
427    #[allow(clippy::too_many_lines)]
428    pub(super) fn prepare_prompt_run(
429        &mut self,
430        command: &RunCommand,
431        prompt_input: Option<PromptInput>,
432    ) -> CliResult<PreparedPromptRun> {
433        let input =
434            prompt_input.map_or_else(|| command.prompt_text().map(PromptInput::text), Ok)?;
435        let raw_prompt = input.text.clone();
436        let worktree = self.resolve_worktree(command)?;
437        let selected_profile = command
438            .profile
439            .as_deref()
440            .unwrap_or(&self.config.default_profile);
441        let resolved_profile = resolve_profile(&self.config, Some(selected_profile))?;
442        let slash_expansion = expand_slash_command(&self.config.slash_commands, &raw_prompt);
443        let explicit_skills = slash_expansion
444            .is_none()
445            .then(|| expand_explicit_skills(&resolved_profile.skills, &raw_prompt))
446            .flatten();
447        let prompt = slash_expansion.as_ref().map_or_else(
448            || {
449                explicit_skills
450                    .as_ref()
451                    .map_or_else(|| raw_prompt.clone(), |expanded| expanded.prompt.clone())
452            },
453            |expanded| expanded.prompt.clone(),
454        );
455        let mut run_input = PromptInput {
456            text: prompt.clone(),
457            attachments: input.attachments,
458            extra_text_parts: input.extra_text_parts,
459            guidance_text_parts: input.guidance_text_parts,
460        };
461        let mut run_config = self.config.clone();
462        if let Some(worktree) = worktree.as_ref() {
463            run_config.workspace_root.clone_from(&worktree.path);
464        }
465        validate_environment_config(&run_config)?;
466        append_guidance_files(&mut run_input, &run_config);
467        append_explicit_skill_guidance(&mut run_input, explicit_skills.as_ref());
468        let (session_id, created) = self.resolve_session(command, &resolved_profile.name)?;
469        if command.run.is_some() || command.branch_from.is_some() {
470            self.reject_ordinary_admission_during_waiting_continuation(
471                &session_id,
472                command.hitl_resume,
473            )?;
474        }
475        let environment = resolve_environment_for_session_with_attachments(
476            &run_config,
477            &session_id,
478            &command.environment_attachments,
479        )?;
480        let hitl_resume_source_run_id = command
481            .hitl_resume
482            .then(|| {
483                command
484                    .run
485                    .as_deref()
486                    .ok_or_else(|| {
487                        CliError::Usage("HITL continuation requires a source run id".to_string())
488                    })
489                    .map(RunId::from_string)
490            })
491            .transpose()?;
492        let mut restore_from = command.run.clone().or_else(|| command.branch_from.clone());
493        if restore_from.is_none() && !created {
494            restore_from = self
495                .store()?
496                .load_session(&session_id)
497                .ok()
498                .and_then(|session| {
499                    session
500                        .active_run_id
501                        .or(session.head_run_id)
502                        .or(session.head_success_run_id)
503                        .map(|run| run.as_str().to_string())
504                });
505        }
506        let mut waiting_restore_without_claim = None;
507        if let Some(source_run_id) = restore_from.as_deref() {
508            let source = self.store()?.load_run(&session_id, source_run_id)?;
509            if restore_requires_hitl_claim(
510                source.status,
511                command.hitl_resume,
512                command.branch_from.is_some(),
513            ) {
514                waiting_restore_without_claim = Some(source_run_id.to_string());
515            }
516            if source.status.is_active()
517                && source.status != RunStatus::Waiting
518                && command.branch_from.is_none()
519                && let Some(waiting_source_run_id) = source.restore_from_run_id.as_ref()
520                && self
521                    .store()?
522                    .load_run(&session_id, waiting_source_run_id.as_str())?
523                    .status
524                    == RunStatus::Waiting
525            {
526                return Err(CliError::Run(format!(
527                    "run {source_run_id} is continuing waiting run {}; wait for it to finish before starting another prompt",
528                    waiting_source_run_id.as_str()
529                )));
530            }
531        }
532        let restore_state = self
533            .store()?
534            .load_restore_state(&session_id, restore_from.as_deref())?;
535        if let Some(source_run_id) = waiting_restore_without_claim {
536            return Err(CliError::Run(format!(
537                "run {source_run_id} is waiting and requires an explicit HITL resume"
538            )));
539        }
540        write_current_session(&self.config, &session_id)?;
541        self.reject_ordinary_admission_during_waiting_continuation(
542            &session_id,
543            command.hitl_resume,
544        )?;
545        let hitl_resume_claim = hitl_resume_source_run_id.map(|source_run_id| {
546            HitlResumeClaim::new(
547                format!("cli-hitl-resume-{}", uuid::Uuid::new_v4()),
548                SessionId::from_string(&session_id),
549                source_run_id,
550                Utc::now(),
551            )
552        });
553        if let Some(claim) = hitl_resume_claim.clone() {
554            run_hitl_resume_claim_operation(
555                self.config.clone(),
556                HitlResumeClaimOperation::Claim(claim),
557            )?;
558        }
559        let mut run = match self.store()?.append_run(
560            &session_id,
561            prompt,
562            restore_from,
563            &resolved_profile.name,
564        ) {
565            Ok(run) => run,
566            Err(error) => {
567                if let Some(claim) = hitl_resume_claim.as_ref() {
568                    let release = run_hitl_resume_claim_operation(
569                        self.config.clone(),
570                        HitlResumeClaimOperation::Release {
571                            session_id: claim.session_id.clone(),
572                            run_id: claim.run_id.clone(),
573                            claim_id: claim.claim_id.clone(),
574                        },
575                    );
576                    if let Err(release_error) = release {
577                        return Err(CliError::Storage(format!(
578                            "{error}; failed to release preflight HITL claim: {release_error}"
579                        )));
580                    }
581                }
582                return Err(error);
583            }
584        };
585        if let Some(claim) = hitl_resume_claim.as_ref() {
586            run.metadata.insert(
587                HITL_RESUME_PREFLIGHT_SOURCE_RUN_ID_METADATA_KEY.to_string(),
588                json!(claim.run_id.as_str()),
589            );
590        }
591        apply_starweaver_run_metadata(
592            &mut run,
593            command,
594            worktree.as_ref(),
595            slash_expansion.as_ref(),
596        );
597        if let Some(explicit_skills) = explicit_skills.as_ref() {
598            run.metadata.insert(
599                "cli.skills.activated".to_string(),
600                json!(
601                    explicit_skills
602                        .skills
603                        .iter()
604                        .map(|skill| json!({
605                            "name": skill.package.name,
606                            "invoked": skill.invoked_name,
607                            "description": skill.package.description,
608                            "path": skill.package.path,
609                            "metadata": skill.package.metadata,
610                        }))
611                        .collect::<Vec<_>>()
612                ),
613            );
614        }
615        if let Some(session_affinity_id) = command.session_affinity_id.as_deref() {
616            run.metadata.insert(
617                "starweaver.session_affinity_id".to_string(),
618                json!(session_affinity_id),
619            );
620        }
621        if !command.environment_attachments.is_empty() {
622            run.metadata.insert(
623                "starweaver.environment_attachments".to_string(),
624                json!(command.environment_attachments),
625            );
626            run.metadata.insert(
627                "starweaver.environment_attachment_ids".to_string(),
628                json!(
629                    command
630                        .environment_attachments
631                        .iter()
632                        .map(|attachment| attachment.id.as_str())
633                        .collect::<Vec<_>>()
634                ),
635            );
636        }
637        let hitl = command.hitl.unwrap_or(self.config.default_hitl);
638        let goal = command
639            .goal
640            .as_ref()
641            .map(|goal| crate::runner::CliGoalRunPolicy {
642                objective: goal.objective.clone(),
643                max_iterations: goal.max_iterations.max(1),
644            });
645        let output_mode = command.output.unwrap_or(self.config.default_output);
646        Ok(PreparedPromptRun {
647            session_id,
648            run_id: run.run_id.as_str().to_string(),
649            output_mode,
650            run,
651            run_input,
652            resolved_profile,
653            environment,
654            restore_state,
655            policy: CliRunPolicy { hitl, goal },
656            execution_host: if command.worker.is_some() || command.worker_label.is_some() {
657                CliAgentExecutionHost::disabled()
658            } else {
659                CliAgentExecutionHost::blocking()
660            },
661            hitl_resume_claim,
662        })
663    }
664
665    pub(super) fn start_prepared_hitl_resume(
666        &self,
667        prepared: &mut PreparedPromptRun,
668    ) -> CliResult<()> {
669        let Some(claim) = prepared.hitl_resume_claim.take() else {
670            return Ok(());
671        };
672        let session_id = claim.session_id;
673        let source_run_id = claim.run_id;
674        let claim_id = claim.claim_id;
675        if let Err(error) = run_hitl_resume_claim_operation(
676            self.config.clone(),
677            HitlResumeClaimOperation::Start {
678                session_id: session_id.clone(),
679                run_id: source_run_id.clone(),
680                claim_id: claim_id.clone(),
681            },
682        ) {
683            let release = run_hitl_resume_claim_operation(
684                self.config.clone(),
685                HitlResumeClaimOperation::Release {
686                    session_id,
687                    run_id: source_run_id,
688                    claim_id,
689                },
690            );
691            return match release {
692                Ok(()) => Err(error),
693                Err(release_error) => Err(CliError::Storage(format!(
694                    "{error}; failed to release preflight HITL claim: {release_error}"
695                ))),
696            };
697        }
698        prepared.run.metadata.insert(
699            HITL_RESUME_CLAIM_ID_METADATA_KEY.to_string(),
700            json!(claim_id),
701        );
702        prepared.run.metadata.insert(
703            HITL_RESUME_SOURCE_RUN_ID_METADATA_KEY.to_string(),
704            json!(source_run_id.as_str()),
705        );
706        Ok(())
707    }
708
709    pub(super) fn run_prepared_prompt(
710        prepared: PreparedPromptRun,
711        stream_sender: Option<mpsc::SyncSender<AgentStreamRecord>>,
712        steering_channel: Option<CliSteeringChannel>,
713        cancel_receiver: Option<mpsc::Receiver<()>>,
714    ) -> CliResult<ExecutedPromptRun> {
715        let PreparedPromptRun {
716            output_mode,
717            run,
718            run_input,
719            resolved_profile,
720            environment,
721            restore_state,
722            policy,
723            execution_host,
724            ..
725        } = prepared;
726        let result = execute_agent_session_with_host(
727            run_input,
728            &run,
729            &resolved_profile,
730            &environment.provider,
731            environment.process_provider.as_ref(),
732            restore_state,
733            &policy,
734            stream_sender,
735            steering_channel,
736            cancel_receiver,
737            execution_host,
738        );
739        result.map(|execution| ExecutedPromptRun {
740            run,
741            output_mode,
742            execution,
743        })
744    }
745
746    pub(super) fn fail_prepared_prompt_run(
747        &mut self,
748        mut run: RunRecord,
749        error: &CliError,
750    ) -> CliResult<()> {
751        let messages = failed_display_message(&run, &error.to_string());
752        self.store()?
753            .fail_run_with_messages(&mut run, error.to_string(), &messages)
754    }
755
756    pub(super) fn complete_prompt_run(
757        &mut self,
758        executed: ExecutedPromptRun,
759    ) -> CliResult<PromptRunExecution> {
760        let ExecutedPromptRun {
761            mut run,
762            output_mode,
763            execution,
764        } = executed;
765        let execution_failed = execution.artifacts.status == RunStatus::Failed;
766        let output = execution.output;
767        let messages = self
768            .store()?
769            .complete_run(&mut run, output.clone(), execution.artifacts)?;
770        if execution_failed && matches!(output_mode, OutputMode::Text | OutputMode::Silent) {
771            return Err(CliError::Run(output));
772        }
773        self.run_retention_maintenance(run.session_id.as_str())?;
774        Ok(PromptRunExecution {
775            session_id: run.session_id.as_str().to_string(),
776            run_id: run.run_id.as_str().to_string(),
777            status: run_status_name(run.status).to_string(),
778            output_mode,
779            messages,
780        })
781    }
782
783    fn run_retention_maintenance(&mut self, current_session_id: &str) -> CliResult<()> {
784        if !self.config.auto_trim {
785            return Ok(());
786        }
787        let current_keep_runs = self.config.current_session_keep_recent_runs;
788        self.store()?.trim(
789            vec![current_session_id.to_string()],
790            current_keep_runs,
791            false,
792        )?;
793        if !self.should_run_all_sessions_retention()? {
794            return Ok(());
795        }
796        let sessions = self.store()?.all_session_ids()?;
797        let all_sessions_keep_runs = self.config.all_sessions_keep_recent_runs;
798        let older_than = chrono::Duration::days(
799            i64::try_from(self.config.all_sessions_keep_days)
800                .unwrap_or(i64::MAX)
801                .max(0),
802        );
803        self.store()?
804            .trim_with_age(sessions, all_sessions_keep_runs, Some(older_than), false)?;
805        write_last_retention_maintenance(&self.config, chrono::Utc::now())?;
806        Ok(())
807    }
808
809    fn should_run_all_sessions_retention(&self) -> CliResult<bool> {
810        if self.config.all_sessions_keep_days == 0 || self.config.all_sessions_interval_hours == 0 {
811            return Ok(false);
812        }
813        let Some(last_run) = read_last_retention_maintenance(&self.config)? else {
814            return Ok(true);
815        };
816        let elapsed = chrono::Utc::now().signed_duration_since(last_run);
817        Ok(elapsed
818            >= chrono::Duration::hours(
819                i64::try_from(self.config.all_sessions_interval_hours)
820                    .unwrap_or(i64::MAX)
821                    .max(0),
822            ))
823    }
824
825    fn resolve_session(
826        &mut self,
827        command: &RunCommand,
828        profile: &str,
829    ) -> CliResult<(String, bool)> {
830        if command.new_session {
831            let session = self
832                .store()?
833                .create_session(profile, Some("CLI session".to_string()))?;
834            return Ok((session.session_id.as_str().to_string(), true));
835        }
836        if let Some(session_id) = command.session.as_ref() {
837            self.store()?.load_session(session_id)?;
838            return Ok((session_id.clone(), false));
839        }
840        if command.continue_session {
841            if let Some(session_id) = read_current_session(&self.config)?
842                && self.store()?.load_session(&session_id).is_ok()
843            {
844                return Ok((session_id, false));
845            }
846            if let Some(session) = self.store()?.latest_session()? {
847                return Ok((session.session_id.as_str().to_string(), false));
848            }
849        }
850        let session = self
851            .store()?
852            .create_session(profile, Some("CLI session".to_string()))?;
853        Ok((session.session_id.as_str().to_string(), true))
854    }
855
856    fn session(&mut self, command: SessionCommand) -> CliResult<String> {
857        match command {
858            SessionCommand::List(command) => {
859                let sessions = self.store()?.list_sessions(command.limit)?;
860                render_sessions(&sessions, command.output)
861            }
862            SessionCommand::Search(command) => {
863                let output = command.output;
864                let page = self.store()?.search_sessions(search_query(command))?;
865                render_session_search(&page, output)
866            }
867            SessionCommand::Show(command) => {
868                let session = self.store()?.load_session(&command.session_id)?;
869                let runs = self.store()?.list_runs(&command.session_id, command.runs)?;
870                let value = session_value(&session);
871                render_session_show(&value, &runs, command.output)
872            }
873            SessionCommand::Replay(command) => {
874                let messages = self.store()?.replay_display(
875                    &command.session_id,
876                    command.run.as_deref(),
877                    command.after,
878                )?;
879                match command.output {
880                    OutputMode::Text => Ok(render_display_text(&messages)),
881                    OutputMode::DisplayJsonl => render_display_jsonl(&messages),
882                    OutputMode::AguiJsonl => render_agui_jsonl(&messages),
883                    OutputMode::Json => Ok(format!(
884                        "{}\n",
885                        serde_json::to_string(&json!({
886                            "sessionId": command.session_id,
887                            "runId": command.run,
888                            "messages": messages,
889                            "status": "replayed"
890                        }))?
891                    )),
892                    OutputMode::Silent => Ok(format!(
893                        "session_id={}\nmessages={}\nstatus=replayed\n",
894                        command.session_id,
895                        messages.len()
896                    )),
897                }
898            }
899            SessionCommand::Delete(command) => {
900                if !command.yes {
901                    return Err(CliError::Usage(
902                        "pass --yes to delete a local session".to_string(),
903                    ));
904                }
905                let session_id = self.store()?.resolve_session_prefix(&command.session_id)?;
906                let deleted = self.store()?.delete_session(&session_id)?;
907                if read_current_session(&self.config)?.as_deref() == Some(session_id.as_str()) {
908                    let _removed =
909                        remove_file_if_exists(&self.config.project_dir.join("state.json"))?;
910                }
911                render_session_delete(&session_id, deleted, command.output)
912            }
913            SessionCommand::Trim(command) => {
914                let sessions = if command.all {
915                    self.store()?.all_session_ids()?
916                } else if let Some(session_id) = command.session {
917                    vec![session_id]
918                } else {
919                    read_current_session(&self.config)?.into_iter().collect()
920                };
921                let older_than = command
922                    .older_than
923                    .as_deref()
924                    .map(parse_duration)
925                    .transpose()?;
926                let report = self.store()?.trim_with_age(
927                    sessions,
928                    command.keep_runs,
929                    older_than,
930                    command.dry_run,
931                )?;
932                render_trim_report(&report, command.output)
933            }
934        }
935    }
936
937    fn approval(&mut self, command: ApprovalCommand) -> CliResult<String> {
938        match command {
939            ApprovalCommand::List(command) => self.approval_list(&command),
940            ApprovalCommand::Show { approval_id } => {
941                let approval = self.store()?.load_approval(&approval_id)?;
942                Ok(format!("{}\n", serde_json::to_string(&approval)?))
943            }
944            ApprovalCommand::Approve(command) => {
945                self.approval_decision(&command, ApprovalStatus::Approved)
946            }
947            ApprovalCommand::Reject(command) => {
948                self.approval_decision(&command, ApprovalStatus::Denied)
949            }
950        }
951    }
952
953    fn approval_list(&mut self, command: &ApprovalListCommand) -> CliResult<String> {
954        let approvals = self
955            .store()?
956            .list_approvals(command.session.as_deref(), command.run.as_deref())?;
957        render_approvals(&approvals, command.output)
958    }
959
960    fn approval_decision(
961        &mut self,
962        command: &ApprovalDecisionCommand,
963        status: ApprovalStatus,
964    ) -> CliResult<String> {
965        let approval =
966            self.store()?
967                .decide_approval(&command.approval_id, status, command.reason.clone())?;
968        match command.output {
969            OutputMode::Text => Ok(format!(
970                "approval_id={}\nstatus={}\nrun_id={}\n",
971                approval.approval_id,
972                approval_status_name(approval.status),
973                approval.run_id.as_str()
974            )),
975            OutputMode::DisplayJsonl | OutputMode::AguiJsonl | OutputMode::Json => {
976                Ok(format!("{}\n", serde_json::to_string(&approval)?))
977            }
978            OutputMode::Silent => Ok(format!(
979                "approval_id={}\nstatus={}\n",
980                approval.approval_id,
981                approval_status_name(approval.status)
982            )),
983        }
984    }
985
986    fn deferred(&mut self, command: DeferredCommand) -> CliResult<String> {
987        match command {
988            DeferredCommand::List(command) => self.deferred_list(&command),
989            DeferredCommand::Show { deferred_id } => {
990                let deferred = self.store()?.load_deferred_tool(&deferred_id)?;
991                Ok(format!("{}\n", serde_json::to_string(&deferred)?))
992            }
993            DeferredCommand::Complete(command) => self.deferred_complete(&command),
994            DeferredCommand::Fail(command) => self.deferred_fail(&command),
995        }
996    }
997
998    fn deferred_list(&mut self, command: &DeferredListCommand) -> CliResult<String> {
999        let records = self
1000            .store()?
1001            .list_deferred_tools(command.session.as_deref(), command.run.as_deref())?;
1002        render_deferred(&records, command.output)
1003    }
1004
1005    fn deferred_complete(&mut self, command: &DeferredCompleteCommand) -> CliResult<String> {
1006        let value = serde_json::from_str::<Value>(&command.result)
1007            .map_err(|error| CliError::Usage(format!("invalid deferred result JSON: {error}")))?;
1008        let record = self
1009            .store()?
1010            .complete_deferred_tool(&command.deferred_id, value)?;
1011        render_deferred_decision(&record, command.output)
1012    }
1013
1014    fn deferred_fail(&mut self, command: &DeferredFailCommand) -> CliResult<String> {
1015        let record = self
1016            .store()?
1017            .fail_deferred_tool(&command.deferred_id, &command.error)?;
1018        render_deferred_decision(&record, command.output)
1019    }
1020
1021    fn resume(&mut self, command: &ResumeCommand) -> CliResult<String> {
1022        let session_id = self.resolve_session_id(command.session.as_deref())?;
1023        let source_run = self.resolve_resume_run(&session_id, command.run.as_deref())?;
1024        let hitl_resume = source_run.status == RunStatus::Waiting;
1025        let run_command = RunCommand {
1026            prompt: Some(format!(
1027                "{}\n\nResuming from run {} with any persisted approval and deferred-tool decisions.",
1028                command.prompt,
1029                source_run.run_id.as_str()
1030            )),
1031            prompt_parts: Vec::new(),
1032            session: Some(session_id),
1033            continue_session: false,
1034            new_session: false,
1035            run: Some(source_run.run_id.as_str().to_string()),
1036            branch_from: None,
1037            profile: source_run.profile.clone(),
1038            output: command.output,
1039            hitl: command.hitl,
1040            goal: None,
1041            worker: None,
1042            worker_label: None,
1043            worktree: None,
1044            worktree_name: None,
1045            branch: None,
1046            session_affinity_id: None,
1047            environment_attachments: Vec::new(),
1048            hitl_resume,
1049        };
1050        self.run_prompt(&run_command)
1051    }
1052
1053    fn resolve_session_id(&mut self, requested: Option<&str>) -> CliResult<String> {
1054        if let Some(session_id) = requested {
1055            self.store()?.load_session(session_id)?;
1056            return Ok(session_id.to_string());
1057        }
1058        if let Some(session_id) = read_current_session(&self.config)?
1059            && self.store()?.load_session(&session_id).is_ok()
1060        {
1061            return Ok(session_id);
1062        }
1063        self.store()?
1064            .latest_session()?
1065            .map(|session| session.session_id.as_str().to_string())
1066            .ok_or_else(|| CliError::NotFound("session".to_string()))
1067    }
1068
1069    fn resolve_resume_run(
1070        &mut self,
1071        session_id: &str,
1072        requested: Option<&str>,
1073    ) -> CliResult<starweaver_session::RunRecord> {
1074        if let Some(run_id) = requested {
1075            return self.store()?.load_run(session_id, run_id);
1076        }
1077        let session = self.store()?.load_session(session_id)?;
1078        let run_id = session
1079            .active_run_id
1080            .as_ref()
1081            .or(session.head_run_id.as_ref())
1082            .ok_or_else(|| CliError::NotFound("run".to_string()))?;
1083        self.store()?.load_run(session_id, run_id.as_str())
1084    }
1085}
1086
1087fn run_hitl_resume_claim_operation(
1088    config: CliConfig,
1089    operation: HitlResumeClaimOperation,
1090) -> CliResult<()> {
1091    thread::spawn(move || {
1092        let store =
1093            LocalSessionStore::new(config).map_err(|error| CliError::Storage(error.to_string()))?;
1094        let runtime = tokio::runtime::Builder::new_current_thread()
1095            .enable_all()
1096            .build()
1097            .map_err(|error| CliError::Run(error.to_string()))?;
1098        runtime
1099            .block_on(async {
1100                match operation {
1101                    HitlResumeClaimOperation::Claim(claim) => store.claim_hitl_resume(claim).await,
1102                    HitlResumeClaimOperation::Start {
1103                        session_id,
1104                        run_id,
1105                        claim_id,
1106                    } => {
1107                        store
1108                            .mark_hitl_resume_started(&session_id, &run_id, &claim_id)
1109                            .await
1110                    }
1111                    HitlResumeClaimOperation::Release {
1112                        session_id,
1113                        run_id,
1114                        claim_id,
1115                    } => {
1116                        store
1117                            .release_hitl_resume_claim(&session_id, &run_id, &claim_id)
1118                            .await
1119                    }
1120                }
1121            })
1122            .map_err(|error| CliError::Storage(error.to_string()))
1123    })
1124    .join()
1125    .map_err(|_| CliError::Run("HITL resume claim worker panicked".to_string()))?
1126}
1127
1128#[allow(dead_code)]
1129fn start_oauth_refresh_guard(config: &CliConfig) -> CliResult<Option<OAuthRefreshGuard>> {
1130    if !config.oauth_refresh.enabled {
1131        return Ok(None);
1132    }
1133    let models = list_profiles(config)
1134        .into_iter()
1135        .map(|profile| profile.model_id)
1136        .collect::<Vec<_>>();
1137    if config.oauth_refresh.interval_seconds == 0 {
1138        return Err(CliError::Usage(
1139            "invalid oauth_refresh.interval_seconds: value must be positive".to_string(),
1140        ));
1141    }
1142    if config.oauth_refresh.failure_retry_seconds == 0 {
1143        return Err(CliError::Usage(
1144            "invalid oauth_refresh.failure_retry_seconds: value must be positive".to_string(),
1145        ));
1146    }
1147    let mut supervisor = create_oauth_refresh_supervisor_for_models_with_options(
1148        models.iter().map(String::as_str),
1149        Duration::from_secs(config.oauth_refresh.interval_seconds),
1150        Duration::from_secs(config.oauth_refresh.failure_retry_seconds),
1151        config.oauth_refresh.refresh_on_startup,
1152    )
1153    .map_err(oauth_cli_error)?;
1154    let Some(mut supervisor) = supervisor.take() else {
1155        return Ok(None);
1156    };
1157    let (stop_sender, stop_receiver) = mpsc::channel::<()>();
1158    let handle = thread::Builder::new()
1159        .name("starweaver-oauth-refresh".to_string())
1160        .spawn(move || {
1161            let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
1162                .enable_all()
1163                .build()
1164            else {
1165                return;
1166            };
1167            runtime.block_on(async move {
1168                supervisor.start().await;
1169                let _ = tokio::task::spawn_blocking(move || stop_receiver.recv()).await;
1170                supervisor.shutdown().await;
1171            });
1172        })
1173        .map_err(|error| CliError::Run(error.to_string()))?;
1174    Ok(Some(OAuthRefreshGuard {
1175        stop_sender,
1176        handle: Some(handle),
1177    }))
1178}
1179
1180fn render_json_lines<T: serde::Serialize>(items: &[T]) -> CliResult<String> {
1181    items
1182        .iter()
1183        .map(|item| serde_json::to_string(item).map(|line| format!("{line}\n")))
1184        .collect::<Result<String, _>>()
1185        .map_err(CliError::from)
1186}
1187
1188const fn run_status_name(status: starweaver_session::RunStatus) -> &'static str {
1189    status.as_str()
1190}
1191
1192fn parse_duration(value: &str) -> CliResult<chrono::Duration> {
1193    let trimmed = value.trim();
1194    if trimmed.is_empty() {
1195        return Err(CliError::Usage("duration cannot be empty".to_string()));
1196    }
1197    let (number, unit) = trimmed.split_at(
1198        trimmed
1199            .find(|ch: char| !ch.is_ascii_digit())
1200            .unwrap_or(trimmed.len()),
1201    );
1202    let amount = number
1203        .parse::<i64>()
1204        .map_err(|error| CliError::Usage(error.to_string()))?;
1205    let duration = match unit {
1206        "" | "s" | "sec" | "secs" => chrono::Duration::seconds(amount),
1207        "m" | "min" | "mins" => chrono::Duration::minutes(amount),
1208        "h" | "hr" | "hrs" => chrono::Duration::hours(amount),
1209        "d" | "day" | "days" => chrono::Duration::days(amount),
1210        other => return Err(CliError::Usage(format!("unknown duration unit: {other}"))),
1211    };
1212    Ok(duration)
1213}
1214
1215#[cfg(test)]
1216mod tests;