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::{
5    collections::BTreeSet,
6    fs,
7    panic::AssertUnwindSafe,
8    path::Path,
9    sync::{
10        Arc, Mutex,
11        atomic::{AtomicBool, Ordering},
12        mpsc,
13    },
14    thread,
15    time::Duration,
16};
17
18use chrono::Utc;
19use ring::digest::{SHA256, digest};
20use serde_json::{Value, json};
21use starweaver_agent::materialization::STARWEAVER_AGENT_POLICY_VERSION;
22use starweaver_agent::{
23    ContinuationMaterialization, ResolvedAgentMaterialization, ResumableState,
24    environment_binding_class,
25};
26use starweaver_core::{RunId, SessionId, sdk_name};
27use starweaver_oauth_provider::create_oauth_refresh_supervisor_for_models_with_options;
28use starweaver_runtime::AgentStreamRecord;
29use starweaver_session::{
30    ApprovalStatus, ExecutionStatus, HitlResumeAbortOutcome, HitlResumeClaim, PreparedContinuation,
31    RunAdmissionLease, RunRecord, RunStatus, SessionSearchFilter, SessionSearchGranularity,
32    SessionSearchQuery, SessionSearchSource, SessionStatus, SessionStore,
33};
34use starweaver_stream::DisplayMessage;
35
36use crate::{
37    CliError, CliResult,
38    args::{
39        ApprovalCommand, ApprovalDecisionCommand, ApprovalListCommand, Cli, CliCommand,
40        DeferredCommand, DeferredCompleteCommand, DeferredFailCommand, DeferredListCommand,
41        OutputMode, ResumeCommand, RunCommand, SessionCommand, SessionSearchCommand,
42        SessionSearchGranularityArg, SessionSearchSourceArg, SessionSearchStatusArg,
43        StorageCommand, StorageImportLegacyCommand, TuiCommand,
44    },
45    computer_use::CliComputerUseCoordinator,
46    config::{
47        CliConfig, read_current_session, read_last_retention_maintenance,
48        remove_project_state_if_current_session, write_current_session,
49        write_last_retention_maintenance,
50    },
51    environment::{
52        EnvironmentAttachmentAccessMode, ResolvedEnvironment,
53        resolve_environment_for_session_with_attachments, validate_environment_config,
54    },
55    local_store::{
56        HITL_RESUME_CLAIM_ID_METADATA_KEY, HITL_RESUME_PREFLIGHT_SOURCE_RUN_ID_METADATA_KEY,
57        HITL_RESUME_SOURCE_RUN_ID_METADATA_KEY, LocalSessionStore, LocalStore,
58    },
59    profiles::{ResolvedProfile, list_profiles, resolve_profile_with_computer_use},
60    prompt_input::PromptInput,
61    runner::{
62        CliAgentExecutionHost, CliRunPolicy, CliSteeringChannel, execute_agent_session_with_host,
63        failed_display_message, validate_prepared_hitl_continuation,
64    },
65    slash_commands::{ExpandedExplicitSkills, expand_explicit_skills, expand_slash_command},
66};
67
68mod auth;
69mod catalog;
70mod rendering;
71mod setup;
72mod tui;
73mod worktree;
74
75use auth::oauth_cli_error;
76use rendering::{
77    approval_status_name, render_agui_jsonl, render_approvals, render_completion, render_deferred,
78    render_deferred_decision, render_display_jsonl, render_display_text, render_prompt_run_json,
79    render_session_delete, render_session_search, render_session_show, render_sessions,
80    render_trim_report, session_value,
81};
82#[cfg(test)]
83use tui::model_choices;
84use worktree::apply_starweaver_run_metadata;
85
86pub(super) struct PromptRunExecution {
87    pub(super) session_id: String,
88    pub(super) run_id: String,
89    pub(super) status: String,
90    pub(super) output_mode: OutputMode,
91    pub(super) messages: Vec<DisplayMessage>,
92    pub(super) continuation: Option<ContinuationMaterialization>,
93}
94
95pub(super) struct PreparedPromptRun {
96    pub(super) session_id: String,
97    pub(super) run_id: String,
98    pub(super) output_mode: OutputMode,
99    pub(super) run: RunRecord,
100    pub(super) admission: CliRunAdmission,
101    admission_cancel_receiver: Option<mpsc::Receiver<()>>,
102    run_input: PromptInput,
103    resolved_profile: ResolvedProfile,
104    pub(super) environment: ResolvedEnvironment,
105    restore_state: Option<ResumableState>,
106    prepared_continuation: Option<PreparedContinuation>,
107    policy: CliRunPolicy,
108    execution_host: CliAgentExecutionHost,
109    hitl_resume_claim: Option<HitlResumeClaim>,
110}
111
112impl PreparedPromptRun {
113    pub(super) fn set_execution_host(&mut self, execution_host: CliAgentExecutionHost) {
114        self.execution_host = execution_host;
115    }
116}
117
118pub(super) struct ExecutedPromptRun {
119    run: RunRecord,
120    output_mode: OutputMode,
121    execution: crate::runner::CliRunExecution,
122    admission: CliRunAdmission,
123}
124
125enum HitlResumeClaimOperation {
126    Claim(HitlResumeClaim),
127    StartEffect {
128        lease: RunAdmissionLease,
129        source_run_id: RunId,
130        claim_id: String,
131    },
132    Release {
133        session_id: SessionId,
134        run_id: RunId,
135        claim_id: String,
136    },
137}
138
139const PROJECT_GUIDANCE_TAG: &str = "project-guidance";
140const USER_RULES_TAG: &str = "user-rules";
141
142fn restore_requires_hitl_claim(status: RunStatus, hitl_resume: bool, branch_from: bool) -> bool {
143    status == RunStatus::Waiting && !hitl_resume && !branch_from
144}
145
146fn deterministic_hitl_resume_claim_id(session_id: &str, run_id: &RunId) -> String {
147    const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef";
148    let identity = format!(
149        "starweaver.cli.hitl_resume_claim.v1\0{session_id}\0{}",
150        run_id.as_str()
151    );
152    let digest = digest(&SHA256, identity.as_bytes());
153    let mut fingerprint = String::with_capacity(digest.as_ref().len() * 2);
154    for byte in digest.as_ref() {
155        fingerprint.push(char::from(HEX_DIGITS[usize::from(byte >> 4)]));
156        fingerprint.push(char::from(HEX_DIGITS[usize::from(byte & 0x0f)]));
157    }
158    format!("cli-hitl-resume-{fingerprint}")
159}
160
161fn search_query(command: SessionSearchCommand) -> SessionSearchQuery {
162    let session_statuses = command
163        .status
164        .map(|status| match status {
165            SessionSearchStatusArg::Active => SessionStatus::Active,
166            SessionSearchStatusArg::Archived => SessionStatus::Archived,
167            SessionSearchStatusArg::Failed => SessionStatus::Failed,
168        })
169        .into_iter()
170        .collect();
171    let sources = command
172        .sources
173        .into_iter()
174        .map(|source| match source {
175            SessionSearchSourceArg::SessionMetadata => SessionSearchSource::SessionMetadata,
176            SessionSearchSourceArg::RunInput => SessionSearchSource::RunInput,
177            SessionSearchSourceArg::RunOutputPreview => SessionSearchSource::RunOutputPreview,
178            SessionSearchSourceArg::DisplayMessage => SessionSearchSource::DisplayMessage,
179        })
180        .collect::<BTreeSet<_>>();
181    let granularity = match command.granularity {
182        SessionSearchGranularityArg::Session => SessionSearchGranularity::Session,
183        SessionSearchGranularityArg::Run => SessionSearchGranularity::Run,
184        SessionSearchGranularityArg::Occurrence => SessionSearchGranularity::Occurrence,
185    };
186    SessionSearchQuery {
187        text: command.text,
188        filter: SessionSearchFilter {
189            session_statuses,
190            profile: command.profile,
191            workspace: command.workspace,
192            ..SessionSearchFilter::default()
193        },
194        sources,
195        granularity,
196        limit: command.limit,
197        cursor: command.cursor,
198        ..SessionSearchQuery::default()
199    }
200}
201
202fn append_guidance_files(input: &mut PromptInput, config: &CliConfig) {
203    if let Some(project_guidance) = load_project_guidance(&config.workspace_root) {
204        input.push_guidance_text_part(project_guidance);
205    }
206    if let Some(user_rules) = load_user_rules(&config.global_dir) {
207        input.push_guidance_text_part(user_rules);
208    }
209}
210
211fn append_explicit_skill_guidance(
212    input: &mut PromptInput,
213    explicit_skills: Option<&ExpandedExplicitSkills>,
214) {
215    let Some(explicit_skills) = explicit_skills else {
216        return;
217    };
218    let names = explicit_skills
219        .skills
220        .iter()
221        .map(|skill| skill.package.name.as_str())
222        .collect::<Vec<_>>()
223        .join(", ");
224    input.push_guidance_text_part(format!(
225        "<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>"
226    ));
227    for skill in &explicit_skills.skills {
228        let package = &skill.package;
229        let Some(body) = package.body.as_deref() else {
230            continue;
231        };
232        input.push_guidance_text_part(format!(
233            "<explicitly-activated-skill>\n<name>{}</name>\n<path>{}</path>\n<instructions>\n{}\n</instructions>\n</explicitly-activated-skill>",
234            escape_xml_text(&package.name),
235            escape_xml_text(&package.path),
236            body
237        ));
238    }
239}
240
241fn escape_xml_text(value: &str) -> String {
242    value
243        .replace('&', "&amp;")
244        .replace('<', "&lt;")
245        .replace('>', "&gt;")
246}
247
248fn load_project_guidance(workspace_root: &Path) -> Option<String> {
249    let path = workspace_root.join("AGENTS.md");
250    let content = read_non_empty_utf8_file(&path)?;
251    Some(format!(
252        "<{PROJECT_GUIDANCE_TAG} name=AGENTS.md>\n{content}\n</{PROJECT_GUIDANCE_TAG}>"
253    ))
254}
255
256fn load_user_rules(global_dir: &Path) -> Option<String> {
257    let path = global_dir.join("RULES.md");
258    let content = read_non_empty_utf8_file(&path)?;
259    Some(format!(
260        "<{USER_RULES_TAG} location={}>\n{content}\n</{USER_RULES_TAG}>",
261        path_absolute_posix(&path)
262    ))
263}
264
265fn read_non_empty_utf8_file(path: &Path) -> Option<String> {
266    let content = fs::read_to_string(path).ok()?;
267    (!content.trim().is_empty()).then_some(content)
268}
269
270fn path_absolute_posix(path: &Path) -> String {
271    path.canonicalize()
272        .unwrap_or_else(|_| path.to_path_buf())
273        .display()
274        .to_string()
275        .replace('\\', "/")
276}
277
278#[allow(dead_code)]
279struct OAuthRefreshGuard {
280    stop_sender: mpsc::Sender<()>,
281    handle: Option<thread::JoinHandle<()>>,
282}
283
284impl Drop for OAuthRefreshGuard {
285    fn drop(&mut self) {
286        let _ = self.stop_sender.send(());
287        if let Some(handle) = self.handle.take() {
288            let _ = handle.join();
289        }
290    }
291}
292
293#[derive(Clone)]
294pub(super) struct CliRunAdmission {
295    config: CliConfig,
296    lease: Arc<Mutex<RunAdmissionLease>>,
297    stop_sender: mpsc::Sender<()>,
298    handle: Arc<Mutex<Option<thread::JoinHandle<()>>>>,
299    released: Arc<AtomicBool>,
300    lost: Arc<AtomicBool>,
301}
302
303impl CliRunAdmission {
304    fn start(config: CliConfig, lease: RunAdmissionLease) -> (Self, mpsc::Receiver<()>) {
305        const RETRY_INTERVAL: Duration = Duration::from_secs(1);
306        const SAFETY_MARGIN: chrono::Duration = chrono::Duration::seconds(5);
307
308        let lease = Arc::new(Mutex::new(lease));
309        let (stop_sender, stop_receiver) = mpsc::channel();
310        let (cancel_sender, cancel_receiver) = mpsc::channel();
311        let lost = Arc::new(AtomicBool::new(false));
312        let heartbeat_lease = Arc::clone(&lease);
313        let heartbeat_lost = Arc::clone(&lost);
314        let heartbeat_config = config.clone();
315        let handle = thread::spawn(move || {
316            let mut wait = Duration::from_secs(10);
317            loop {
318                match stop_receiver.recv_timeout(wait) {
319                    Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => break,
320                    Err(mpsc::RecvTimeoutError::Timeout) => {}
321                }
322                let current = if let Ok(lease) = heartbeat_lease.lock() {
323                    lease.clone()
324                } else {
325                    heartbeat_lost.store(true, Ordering::Release);
326                    let _ = cancel_sender.send(());
327                    break;
328                };
329                if let Ok(refreshed) =
330                    LocalStore::heartbeat_run_admission(&heartbeat_config, &current)
331                {
332                    if let Ok(mut lease) = heartbeat_lease.lock() {
333                        *lease = refreshed;
334                    } else {
335                        heartbeat_lost.store(true, Ordering::Release);
336                        let _ = cancel_sender.send(());
337                        break;
338                    }
339                    wait = Duration::from_secs(10);
340                } else {
341                    if Utc::now() + SAFETY_MARGIN >= current.lease_expires_at {
342                        heartbeat_lost.store(true, Ordering::Release);
343                        let _ = cancel_sender.send(());
344                        break;
345                    }
346                    wait = RETRY_INTERVAL;
347                }
348            }
349        });
350        (
351            Self {
352                config,
353                lease,
354                stop_sender,
355                handle: Arc::new(Mutex::new(Some(handle))),
356                released: Arc::new(AtomicBool::new(false)),
357                lost,
358            },
359            cancel_receiver,
360        )
361    }
362
363    fn refresh(&self) -> CliResult<()> {
364        if self.lost.load(Ordering::Acquire) {
365            return Err(CliError::Run(
366                "run admission lease was lost before completion".to_string(),
367            ));
368        }
369        if self.released.load(Ordering::Acquire) {
370            return Err(CliError::Run(
371                "run admission lease was already released".to_string(),
372            ));
373        }
374        let current = self
375            .lease
376            .lock()
377            .map_err(|error| CliError::Storage(error.to_string()))?
378            .clone();
379        let refreshed = LocalStore::heartbeat_run_admission(&self.config, &current)?;
380        *self
381            .lease
382            .lock()
383            .map_err(|error| CliError::Storage(error.to_string()))? = refreshed;
384        Ok(())
385    }
386
387    fn current_lease(&self) -> CliResult<RunAdmissionLease> {
388        if self.lost.load(Ordering::Acquire) {
389            return Err(CliError::Run(
390                "run admission lease was lost before evidence commit".to_string(),
391            ));
392        }
393        self.lease
394            .lock()
395            .map_err(|error| CliError::Storage(error.to_string()))
396            .map(|lease| lease.clone())
397    }
398
399    fn release(&self, store: &LocalStore) -> CliResult<()> {
400        if self.released.swap(true, Ordering::AcqRel) {
401            return Ok(());
402        }
403        let _ = self.stop_sender.send(());
404        if let Ok(mut handle) = self.handle.lock()
405            && let Some(handle) = handle.take()
406        {
407            let _ = handle.join();
408        }
409        let lease = self
410            .lease
411            .lock()
412            .map_err(|error| CliError::Storage(error.to_string()))?
413            .clone();
414        store.release_run_admission(&lease)
415    }
416}
417
418/// CLI service.
419pub struct CliService {
420    config: CliConfig,
421    store: Option<LocalStore>,
422    host_instance_id: String,
423    computer_use: AssertUnwindSafe<Option<CliComputerUseCoordinator>>,
424    owns_computer_use_shutdown: bool,
425}
426
427impl CliService {
428    /// Open service from resolved config.
429    pub fn open(config: CliConfig) -> CliResult<Self> {
430        let computer_use = CliComputerUseCoordinator::from_config(&config.computer_use())?;
431        Ok(Self {
432            config,
433            store: None,
434            host_instance_id: format!("cli-host-{}", uuid::Uuid::new_v4()),
435            computer_use: AssertUnwindSafe(computer_use),
436            owns_computer_use_shutdown: true,
437        })
438    }
439
440    pub(super) fn open_with_computer_use(
441        config: CliConfig,
442        computer_use: Option<CliComputerUseCoordinator>,
443    ) -> Self {
444        Self {
445            config,
446            store: None,
447            host_instance_id: format!("cli-host-{}", uuid::Uuid::new_v4()),
448            computer_use: AssertUnwindSafe(computer_use),
449            owns_computer_use_shutdown: false,
450        }
451    }
452
453    fn shutdown_computer_use(&self) -> CliResult<()> {
454        if !self.owns_computer_use_shutdown {
455            return Ok(());
456        }
457        self.computer_use
458            .0
459            .as_ref()
460            .map_or(Ok(()), CliComputerUseCoordinator::shutdown)
461    }
462
463    fn store(&mut self) -> CliResult<&mut LocalStore> {
464        if self.store.is_none() {
465            self.store = Some(LocalStore::open(&self.config)?);
466        }
467        self.store
468            .as_mut()
469            .ok_or_else(|| CliError::Storage("store initialization failed".to_string()))
470    }
471
472    /// Execute a parsed CLI command and always run bounded process-local cleanup.
473    pub fn execute(mut self, cli: Cli) -> CliResult<String> {
474        let command_result = (|| {
475            if let Some(prompt) = cli.prompt.clone() {
476                let command = RunCommand {
477                    prompt: Some(prompt),
478                    prompt_parts: Vec::new(),
479                    session: cli.session.clone(),
480                    continue_session: cli.continue_session,
481                    new_session: cli.new_session,
482                    run: cli.run.clone(),
483                    branch_from: cli.branch_from.clone(),
484                    profile: cli.profile.clone(),
485                    continuation_mode: cli.continuation_mode,
486                    output: cli.output,
487                    hitl: cli.hitl,
488                    goal: None,
489                    worker: cli.worker.clone(),
490                    worker_label: cli.worker_label.clone(),
491                    worktree: cli.worktree.clone(),
492                    worktree_name: cli.worktree_name.clone(),
493                    branch: cli.branch,
494                    session_affinity_id: None,
495                    environment_attachments: Vec::new(),
496                    hitl_resume: false,
497                };
498                return self.run_prompt(&command);
499            }
500            let default_command = CliCommand::Tui(TuiCommand {
501                session: cli.session.clone(),
502                run: cli.run.clone(),
503                after: None,
504                interactive: false,
505                snapshot: false,
506                output: OutputMode::Text,
507                render_mode: None,
508            });
509            match cli.command.unwrap_or(default_command) {
510                CliCommand::Version => Ok(format!("{}\n", sdk_name())),
511                CliCommand::Diagnostics => Ok(self.diagnostics()?),
512                CliCommand::ReplayCheck => {
513                    Ok("run `make replay-check` from the repository root\n".to_string())
514                }
515                CliCommand::Update(command) => Self::update(&command),
516                CliCommand::Run(command) => self.run_prompt(&command),
517                CliCommand::Session { command } => self.session(command),
518                CliCommand::Storage { command } => self.storage(command),
519                CliCommand::Profile { command } => self.profile(command),
520                CliCommand::Setup(command) => self.setup(&command),
521                CliCommand::Auth { command } => Self::auth(command),
522                CliCommand::Skill { command } => self.skills(command),
523                CliCommand::Subagent { command } => self.subagents(command),
524                CliCommand::Mcp { command } => self.mcp(command),
525                CliCommand::Tools { command } => self.tools(&command),
526                CliCommand::Tui(command) => self.tui(&command),
527                CliCommand::Approval { command } => self.approval(command),
528                CliCommand::Deferred { command } => self.deferred(command),
529                CliCommand::Resume(command) => self.resume(&command),
530                CliCommand::Reset(command) => self.reset(&command),
531                CliCommand::Config { command } => self.config(command),
532                CliCommand::Completion { shell } => render_completion(shell),
533            }
534        })();
535        let cleanup_result = self.shutdown_computer_use();
536        // The result below now owns cleanup reporting; prevent Drop from
537        // repeating a sticky mandatory-cleanup error.
538        self.owns_computer_use_shutdown = false;
539        match (command_result, cleanup_result) {
540            (Ok(output), Ok(())) => Ok(output),
541            (Err(error), Ok(())) => Err(error),
542            (Ok(_), Err(cleanup)) => Err(cleanup),
543            (Err(error), Err(cleanup)) => Err(CliError::Run(format!(
544                "{error}; additional shutdown failure: {cleanup}"
545            ))),
546        }
547    }
548
549    pub(crate) fn run_prompt(&mut self, command: &RunCommand) -> CliResult<String> {
550        let execution = self.execute_prompt_run(command, None)?;
551        match execution.output_mode {
552            OutputMode::Text => {
553                let output = render_display_text(&execution.messages);
554                Ok(continuation_drift_prefix(execution.continuation.as_ref()) + &output)
555            }
556            OutputMode::DisplayJsonl => render_display_jsonl(&execution.messages),
557            OutputMode::AguiJsonl => render_agui_jsonl(&execution.messages),
558            OutputMode::Json => render_prompt_run_json(&execution),
559            OutputMode::Silent => Ok(format!(
560                "{}session_id={}\nrun_id={}\nstatus={}\n",
561                continuation_drift_prefix(execution.continuation.as_ref()),
562                execution.session_id,
563                execution.run_id,
564                execution.status
565            )),
566        }
567    }
568
569    fn execute_prompt_run(
570        &mut self,
571        command: &RunCommand,
572        stream_sender: Option<mpsc::SyncSender<AgentStreamRecord>>,
573    ) -> CliResult<PromptRunExecution> {
574        self.execute_prompt_run_with_channels(command, None, stream_sender, None, None)
575    }
576
577    #[allow(clippy::too_many_lines)]
578    fn execute_prompt_run_with_channels(
579        &mut self,
580        command: &RunCommand,
581        prompt_input: Option<PromptInput>,
582        stream_sender: Option<mpsc::SyncSender<AgentStreamRecord>>,
583        steering_channel: Option<CliSteeringChannel>,
584        cancel_receiver: Option<mpsc::Receiver<()>>,
585    ) -> CliResult<PromptRunExecution> {
586        let mut prepared = self.prepare_prompt_run(command, prompt_input)?;
587        let admission_on_error = prepared.admission.clone();
588        if let Err(error) = self.start_prepared_hitl_resume(&mut prepared) {
589            // The phase-aware start path records the source claim relation before it attempts
590            // the durable fence. Preserve that mutated run when cleanup must consume Started.
591            self.fail_prepared_prompt_run(prepared.run.clone(), &error, &admission_on_error)?;
592            return Err(error);
593        }
594        let run_on_error = prepared.run.clone();
595        let admission_on_error = prepared.admission.clone();
596        let executed = match Self::run_prepared_prompt(
597            prepared,
598            stream_sender,
599            steering_channel,
600            cancel_receiver,
601        ) {
602            Ok(executed) => executed,
603            Err(error) => {
604                self.fail_prepared_prompt_run(run_on_error, &error, &admission_on_error)?;
605                return Err(error);
606            }
607        };
608        self.complete_prompt_run(executed)
609    }
610
611    fn reject_ordinary_admission_during_waiting_continuation(
612        &mut self,
613        session_id: &str,
614        hitl_resume: bool,
615    ) -> CliResult<()> {
616        if hitl_resume {
617            return Ok(());
618        }
619        let session = self.store()?.load_session(session_id)?;
620        let runs = self.store()?.list_run_records(session_id)?;
621        let active_run_id = session.active_run_id.as_ref();
622        for run in runs.iter().filter(|run| {
623            active_run_id == Some(&run.run_id)
624                || (run.status.is_active() && run.status != RunStatus::Waiting)
625        }) {
626            let waiting_source_run_id = if run.status == RunStatus::Waiting {
627                Some(&run.run_id)
628            } else {
629                run.restore_from_run_id.as_ref().filter(|source_run_id| {
630                    runs.iter().any(|source| {
631                        source.run_id.as_str() == source_run_id.as_str()
632                            && source.status == RunStatus::Waiting
633                    })
634                })
635            };
636            if let Some(waiting_source_run_id) = waiting_source_run_id {
637                return Err(CliError::Run(format!(
638                    "run {} is continuing waiting run {}; wait for it to finish before starting another prompt",
639                    run.run_id.as_str(),
640                    waiting_source_run_id.as_str()
641                )));
642            }
643        }
644        Ok(())
645    }
646
647    #[allow(clippy::too_many_lines)]
648    pub(super) fn prepare_prompt_run(
649        &mut self,
650        command: &RunCommand,
651        prompt_input: Option<PromptInput>,
652    ) -> CliResult<PreparedPromptRun> {
653        let input =
654            prompt_input.map_or_else(|| command.prompt_text().map(PromptInput::text), Ok)?;
655        let raw_prompt = input.text.clone();
656        let worktree = self.resolve_worktree(command)?;
657        let selected_profile = command
658            .profile
659            .as_deref()
660            .unwrap_or(&self.config.default_profile);
661        let resolved_profile = resolve_profile_with_computer_use(
662            &self.config,
663            Some(selected_profile),
664            self.computer_use.0.clone(),
665        )?;
666        let slash_expansion = expand_slash_command(&self.config.slash_commands, &raw_prompt);
667        let explicit_skills = slash_expansion
668            .is_none()
669            .then(|| expand_explicit_skills(&resolved_profile.skills, &raw_prompt))
670            .flatten();
671        let prompt = slash_expansion.as_ref().map_or_else(
672            || {
673                explicit_skills
674                    .as_ref()
675                    .map_or_else(|| raw_prompt.clone(), |expanded| expanded.prompt.clone())
676            },
677            |expanded| expanded.prompt.clone(),
678        );
679        let mut run_input = PromptInput {
680            text: prompt.clone(),
681            attachments: input.attachments,
682            extra_text_parts: input.extra_text_parts,
683            guidance_text_parts: input.guidance_text_parts,
684        };
685        let mut run_config = self.config.clone();
686        if let Some(worktree) = worktree.as_ref() {
687            run_config.workspace_root.clone_from(&worktree.path);
688        }
689        validate_environment_config(&run_config)?;
690        append_guidance_files(&mut run_input, &run_config);
691        append_explicit_skill_guidance(&mut run_input, explicit_skills.as_ref());
692        let (session_id, created) = self.resolve_session(command, &resolved_profile.name)?;
693        if command.run.is_some() || command.branch_from.is_some() {
694            self.reject_ordinary_admission_during_waiting_continuation(
695                &session_id,
696                command.hitl_resume,
697            )?;
698        }
699        let environment = resolve_environment_for_session_with_attachments(
700            &run_config,
701            &session_id,
702            &command.environment_attachments,
703        )?;
704        let hitl_resume_source_run_id = command
705            .hitl_resume
706            .then(|| {
707                command
708                    .run
709                    .as_deref()
710                    .ok_or_else(|| {
711                        CliError::Usage("HITL continuation requires a source run id".to_string())
712                    })
713                    .map(RunId::from_string)
714            })
715            .transpose()?;
716        let mut restore_from = command.run.clone().or_else(|| command.branch_from.clone());
717        if restore_from.is_none() && !created {
718            restore_from = self
719                .store()?
720                .load_session(&session_id)
721                .ok()
722                .and_then(|session| {
723                    session
724                        .active_run_id
725                        .or(session.head_run_id)
726                        .or(session.head_success_run_id)
727                        .map(|run| run.as_str().to_string())
728                });
729        }
730        let mut waiting_restore_without_claim = None;
731        if let Some(source_run_id) = restore_from.as_deref() {
732            let source = self.store()?.load_run(&session_id, source_run_id)?;
733            if restore_requires_hitl_claim(
734                source.status,
735                command.hitl_resume,
736                command.branch_from.is_some(),
737            ) {
738                waiting_restore_without_claim = Some(source_run_id.to_string());
739            }
740            if source.status.is_active()
741                && source.status != RunStatus::Waiting
742                && command.branch_from.is_none()
743                && let Some(waiting_source_run_id) = source.restore_from_run_id.as_ref()
744                && self
745                    .store()?
746                    .load_run(&session_id, waiting_source_run_id.as_str())?
747                    .status
748                    == RunStatus::Waiting
749            {
750                return Err(CliError::Run(format!(
751                    "run {source_run_id} is continuing waiting run {}; wait for it to finish before starting another prompt",
752                    waiting_source_run_id.as_str()
753                )));
754            }
755        }
756        let prepared_continuation = match hitl_resume_source_run_id.as_ref() {
757            Some(source_run_id) => Some(
758                self.store()?
759                    .prepare_waiting_continuation(&session_id, source_run_id.as_str())?,
760            ),
761            None => None,
762        };
763        let restore_state = match prepared_continuation.as_ref() {
764            Some(prepared) => Some(prepared.snapshot.state.clone()),
765            None => self
766                .store()?
767                .load_restore_state(&session_id, restore_from.as_deref())?,
768        };
769        if let Some(source_run_id) = waiting_restore_without_claim {
770            let mut pending = self
771                .store()?
772                .list_approvals(Some(&session_id), Some(&source_run_id))?
773                .into_iter()
774                .filter(|approval| approval.status == ApprovalStatus::Pending)
775                .map(|approval| approval.approval_id)
776                .collect::<Vec<_>>();
777            pending.extend(
778                self.store()?
779                    .list_deferred_tools(Some(&session_id), Some(&source_run_id))?
780                    .into_iter()
781                    .filter(|deferred| {
782                        matches!(
783                            deferred.status,
784                            ExecutionStatus::Pending
785                                | ExecutionStatus::Running
786                                | ExecutionStatus::Waiting
787                        )
788                    })
789                    .map(|deferred| deferred.deferred_id),
790            );
791            if !pending.is_empty() {
792                return Err(CliError::Run(format!(
793                    "cannot resume run {source_run_id} while HITL items are pending: {}",
794                    pending.join(", ")
795                )));
796            }
797            return Err(CliError::Run(format!(
798                "run {source_run_id} is waiting and requires an explicit HITL resume"
799            )));
800        }
801        let binding_class = environment_binding_class(environment.attachments.iter().map(|item| {
802            (
803                item.kind.clone(),
804                match item.resolved_mode() {
805                    EnvironmentAttachmentAccessMode::ReadOnly => "read_only",
806                    EnvironmentAttachmentAccessMode::ReadWrite => "read_write",
807                }
808                .to_string(),
809            )
810        }));
811        let materialization = resolved_profile
812            .spec
813            .resolved_materialization(
814                &resolved_profile.registry,
815                STARWEAVER_AGENT_POLICY_VERSION,
816                binding_class,
817            )
818            .map_err(|error| CliError::Config(error.to_string()))?;
819        let continuation = restore_from
820            .as_deref()
821            .map(|source_run_id| {
822                let source = self.store()?.load_run(&session_id, source_run_id)?;
823                let source_materialization =
824                    ResolvedAgentMaterialization::from_metadata(&source.metadata)
825                        .map_err(|error| CliError::Run(error.to_string()))?;
826                let assessment = ContinuationMaterialization::assess(
827                    source_materialization.as_ref(),
828                    &materialization,
829                    command.continuation_mode.into(),
830                );
831                if !assessment.allowed {
832                    return Err(CliError::Run(format!(
833                        "continuation materialization mode {} rejected drift: {}; retry with --continuation-mode compatible or switch after review",
834                        assessment.mode.as_str(),
835                        assessment.drift_summary()
836                    )));
837                }
838                Ok(assessment)
839            })
840            .transpose()?;
841        if let Some(prepared) = prepared_continuation.as_ref() {
842            validate_prepared_hitl_continuation(
843                &resolved_profile,
844                &environment.provider,
845                environment.process_provider.as_ref(),
846                prepared,
847            )?;
848        }
849        let mut admission_metadata = serde_json::Map::new();
850        materialization
851            .insert_into(&mut admission_metadata)
852            .map_err(CliError::from)?;
853        if let Some(continuation) = continuation.as_ref() {
854            continuation
855                .insert_into(&mut admission_metadata)
856                .map_err(CliError::from)?;
857        }
858        write_current_session(&self.config, &session_id)?;
859        self.reject_ordinary_admission_during_waiting_continuation(
860            &session_id,
861            command.hitl_resume,
862        )?;
863        let hitl_resume_claim = hitl_resume_source_run_id.clone().map(|source_run_id| {
864            HitlResumeClaim::new(
865                deterministic_hitl_resume_claim_id(&session_id, &source_run_id),
866                SessionId::from_string(&session_id),
867                source_run_id,
868                Utc::now(),
869            )
870        });
871        if let Some(claim) = hitl_resume_claim.clone() {
872            run_hitl_resume_claim_operation(
873                self.config.clone(),
874                HitlResumeClaimOperation::Claim(claim),
875            )?;
876        }
877        let host_instance_id = self.host_instance_id.clone();
878        let (mut run, admission_lease) = match self.store()?.admit_run(
879            &session_id,
880            prompt,
881            restore_from,
882            &resolved_profile.name,
883            admission_metadata,
884            &host_instance_id,
885            hitl_resume_source_run_id,
886            hitl_resume_claim
887                .as_ref()
888                .map(|claim| claim.claim_id.clone()),
889        ) {
890            Ok(admitted) => admitted,
891            Err(error) => {
892                if let Some(claim) = hitl_resume_claim.as_ref() {
893                    let release = run_hitl_resume_claim_operation(
894                        self.config.clone(),
895                        HitlResumeClaimOperation::Release {
896                            session_id: claim.session_id.clone(),
897                            run_id: claim.run_id.clone(),
898                            claim_id: claim.claim_id.clone(),
899                        },
900                    );
901                    if let Err(release_error) = release {
902                        return Err(CliError::Storage(format!(
903                            "{error}; failed to release preflight HITL claim: {release_error}"
904                        )));
905                    }
906                }
907                return Err(error);
908            }
909        };
910        let (admission, admission_cancel_receiver) =
911            CliRunAdmission::start(self.config.clone(), admission_lease);
912        if let Some(claim) = hitl_resume_claim.as_ref() {
913            run.metadata.insert(
914                HITL_RESUME_PREFLIGHT_SOURCE_RUN_ID_METADATA_KEY.to_string(),
915                json!(claim.run_id.as_str()),
916            );
917        }
918        apply_starweaver_run_metadata(
919            &mut run,
920            command,
921            worktree.as_ref(),
922            slash_expansion.as_ref(),
923        );
924        if let Some(explicit_skills) = explicit_skills.as_ref() {
925            run.metadata.insert(
926                "cli.skills.activated".to_string(),
927                json!(
928                    explicit_skills
929                        .skills
930                        .iter()
931                        .map(|skill| json!({
932                            "name": skill.package.name,
933                            "invoked": skill.invoked_name,
934                            "description": skill.package.description,
935                            "path": skill.package.path,
936                            "metadata": skill.package.metadata,
937                        }))
938                        .collect::<Vec<_>>()
939                ),
940            );
941        }
942        if let Some(session_affinity_id) = command.session_affinity_id.as_deref() {
943            run.metadata.insert(
944                "starweaver.session_affinity_id".to_string(),
945                json!(session_affinity_id),
946            );
947        }
948        if !command.environment_attachments.is_empty() {
949            run.metadata.insert(
950                "starweaver.environment_attachments".to_string(),
951                json!(command.environment_attachments),
952            );
953            run.metadata.insert(
954                "starweaver.environment_attachment_ids".to_string(),
955                json!(
956                    command
957                        .environment_attachments
958                        .iter()
959                        .map(|attachment| attachment.id.as_str())
960                        .collect::<Vec<_>>()
961                ),
962            );
963        }
964        let hitl = command.hitl.unwrap_or(self.config.default_hitl);
965        let goal = command
966            .goal
967            .as_ref()
968            .map(|goal| crate::runner::CliGoalRunPolicy {
969                objective: goal.objective.clone(),
970                max_iterations: goal.max_iterations.max(1),
971            });
972        let output_mode = command.output.unwrap_or(self.config.default_output);
973        Ok(PreparedPromptRun {
974            session_id,
975            run_id: run.run_id.as_str().to_string(),
976            output_mode,
977            run,
978            admission,
979            admission_cancel_receiver: Some(admission_cancel_receiver),
980            run_input,
981            resolved_profile,
982            environment,
983            restore_state,
984            prepared_continuation,
985            policy: CliRunPolicy { hitl, goal },
986            execution_host: if command.worker.is_some() || command.worker_label.is_some() {
987                CliAgentExecutionHost::disabled()
988            } else {
989                CliAgentExecutionHost::blocking()
990            },
991            hitl_resume_claim,
992        })
993    }
994
995    /// Advance a fenced waiting-run claim immediately before approved-tool execution.
996    ///
997    /// The shared store transition is the only operation that moves an admitted continuation to
998    /// `Started`; after it succeeds a process loss is deliberately reconciled as indeterminate
999    /// rather than allowing a second product to repeat the approved effect.
1000    pub(super) fn start_prepared_hitl_resume(
1001        &self,
1002        prepared: &mut PreparedPromptRun,
1003    ) -> CliResult<()> {
1004        let Some(claim) = prepared.hitl_resume_claim.take() else {
1005            return Ok(());
1006        };
1007        let source_run_id = claim.run_id;
1008        let claim_id = claim.claim_id;
1009        // Record the relation before attempting the effect fence. If the store committed
1010        // `Started` but its response was lost, ordinary error cleanup must atomically consume
1011        // this source claim instead of terminalizing only the replacement.
1012        prepared.run.metadata.insert(
1013            HITL_RESUME_CLAIM_ID_METADATA_KEY.to_string(),
1014            json!(claim_id),
1015        );
1016        prepared.run.metadata.insert(
1017            HITL_RESUME_SOURCE_RUN_ID_METADATA_KEY.to_string(),
1018            json!(source_run_id.as_str()),
1019        );
1020        let lease_before_refresh = prepared.admission.current_lease()?;
1021        if let Err(refresh_error) = prepared.admission.refresh() {
1022            return Err(phase_aware_hitl_start_error(
1023                self.config.clone(),
1024                lease_before_refresh,
1025                source_run_id,
1026                claim_id,
1027                refresh_error,
1028            ));
1029        }
1030        let lease = prepared.admission.current_lease()?;
1031        if let Err(start_error) = run_hitl_resume_claim_operation(
1032            self.config.clone(),
1033            HitlResumeClaimOperation::StartEffect {
1034                lease: lease.clone(),
1035                source_run_id: source_run_id.clone(),
1036                claim_id: claim_id.clone(),
1037            },
1038        ) {
1039            return Err(phase_aware_hitl_start_error(
1040                self.config.clone(),
1041                lease,
1042                source_run_id,
1043                claim_id,
1044                start_error,
1045            ));
1046        }
1047        Ok(())
1048    }
1049
1050    pub(super) fn run_prepared_prompt(
1051        prepared: PreparedPromptRun,
1052        stream_sender: Option<mpsc::SyncSender<AgentStreamRecord>>,
1053        steering_channel: Option<CliSteeringChannel>,
1054        cancel_receiver: Option<mpsc::Receiver<()>>,
1055    ) -> CliResult<ExecutedPromptRun> {
1056        let PreparedPromptRun {
1057            output_mode,
1058            run,
1059            admission,
1060            admission_cancel_receiver,
1061            run_input,
1062            resolved_profile,
1063            environment,
1064            restore_state,
1065            prepared_continuation,
1066            policy,
1067            execution_host,
1068            ..
1069        } = prepared;
1070        let result = execute_agent_session_with_host(
1071            run_input,
1072            &run,
1073            &resolved_profile,
1074            &environment.provider,
1075            environment.process_provider.as_ref(),
1076            restore_state,
1077            prepared_continuation.as_ref(),
1078            &policy,
1079            stream_sender,
1080            steering_channel,
1081            cancel_receiver,
1082            admission_cancel_receiver,
1083            execution_host,
1084        );
1085        result.map(|execution| ExecutedPromptRun {
1086            run,
1087            output_mode,
1088            execution,
1089            admission,
1090        })
1091    }
1092
1093    pub(super) fn fail_prepared_prompt_run(
1094        &mut self,
1095        mut run: RunRecord,
1096        _error: &CliError,
1097        admission: &CliRunAdmission,
1098    ) -> CliResult<()> {
1099        // `abort_admitted_hitl_resume` has already terminalized an admitted replacement in the
1100        // no-effect branch. Do not overwrite that durable decision with generic evidence; just
1101        // release its matching lease. Started and uncertain branches remain non-terminal here,
1102        // so the normal fenced evidence path consumes the source claim recorded on `run`.
1103        if self
1104            .store()?
1105            .load_run(run.session_id.as_str(), run.run_id.as_str())?
1106            .status
1107            .is_terminal()
1108        {
1109            return admission.release(self.store()?);
1110        }
1111        admission.refresh()?;
1112        let lease = admission.current_lease()?;
1113        let message = "CLI run failed".to_string();
1114        let messages = failed_display_message(&run, &message);
1115        self.store()?
1116            .fail_run_with_messages_fenced(&mut run, message, &messages, &lease)?;
1117        admission.release(self.store()?)
1118    }
1119
1120    pub(super) fn complete_prompt_run(
1121        &mut self,
1122        executed: ExecutedPromptRun,
1123    ) -> CliResult<PromptRunExecution> {
1124        let ExecutedPromptRun {
1125            mut run,
1126            output_mode,
1127            execution,
1128            admission,
1129        } = executed;
1130        admission.refresh()?;
1131        let execution_failed = execution.artifacts.status == RunStatus::Failed;
1132        let output = execution.output;
1133        let continuation = run
1134            .metadata
1135            .get(starweaver_agent::AGENT_CONTINUATION_METADATA_KEY)
1136            .cloned()
1137            .map(serde_json::from_value)
1138            .transpose()?;
1139        let lease = admission.current_lease()?;
1140        let messages = self.store()?.complete_run_fenced(
1141            &mut run,
1142            output.clone(),
1143            execution.artifacts,
1144            &lease,
1145        )?;
1146        admission.release(self.store()?)?;
1147        if execution_failed && matches!(output_mode, OutputMode::Text | OutputMode::Silent) {
1148            return Err(CliError::Run(output));
1149        }
1150        self.run_retention_maintenance(run.session_id.as_str())?;
1151        Ok(PromptRunExecution {
1152            session_id: run.session_id.as_str().to_string(),
1153            run_id: run.run_id.as_str().to_string(),
1154            status: run_status_name(run.status).to_string(),
1155            output_mode,
1156            messages,
1157            continuation,
1158        })
1159    }
1160
1161    fn run_retention_maintenance(&mut self, current_session_id: &str) -> CliResult<()> {
1162        if !self.config.auto_trim {
1163            return Ok(());
1164        }
1165        let current_keep_runs = self.config.current_session_keep_recent_runs;
1166        self.store()?.trim(
1167            vec![current_session_id.to_string()],
1168            current_keep_runs,
1169            false,
1170        )?;
1171        if !self.should_run_all_sessions_retention()? {
1172            return Ok(());
1173        }
1174        let sessions = self.store()?.all_session_ids()?;
1175        let all_sessions_keep_runs = self.config.all_sessions_keep_recent_runs;
1176        let older_than = chrono::Duration::days(
1177            i64::try_from(self.config.all_sessions_keep_days)
1178                .unwrap_or(i64::MAX)
1179                .max(0),
1180        );
1181        self.store()?
1182            .trim_with_age(sessions, all_sessions_keep_runs, Some(older_than), false)?;
1183        write_last_retention_maintenance(&self.config, chrono::Utc::now())?;
1184        Ok(())
1185    }
1186
1187    fn should_run_all_sessions_retention(&self) -> CliResult<bool> {
1188        if self.config.all_sessions_keep_days == 0 || self.config.all_sessions_interval_hours == 0 {
1189            return Ok(false);
1190        }
1191        let Some(last_run) = read_last_retention_maintenance(&self.config)? else {
1192            return Ok(true);
1193        };
1194        let elapsed = chrono::Utc::now().signed_duration_since(last_run);
1195        Ok(elapsed
1196            >= chrono::Duration::hours(
1197                i64::try_from(self.config.all_sessions_interval_hours)
1198                    .unwrap_or(i64::MAX)
1199                    .max(0),
1200            ))
1201    }
1202
1203    fn resolve_session(
1204        &mut self,
1205        command: &RunCommand,
1206        profile: &str,
1207    ) -> CliResult<(String, bool)> {
1208        if command.new_session {
1209            let session = self
1210                .store()?
1211                .create_session(profile, Some("CLI session".to_string()))?;
1212            return Ok((session.session_id.as_str().to_string(), true));
1213        }
1214        if let Some(session_id) = command.session.as_ref() {
1215            self.store()?.load_session(session_id)?;
1216            return Ok((session_id.clone(), false));
1217        }
1218        if command.continue_session {
1219            if let Some(session_id) = read_current_session(&self.config)?
1220                && self.store()?.load_workspace_session(&session_id).is_ok()
1221            {
1222                return Ok((session_id, false));
1223            }
1224            if let Some(session) = self.store()?.latest_session()? {
1225                return Ok((session.session_id.as_str().to_string(), false));
1226            }
1227        }
1228        let session = self
1229            .store()?
1230            .create_session(profile, Some("CLI session".to_string()))?;
1231        Ok((session.session_id.as_str().to_string(), true))
1232    }
1233
1234    fn storage(&mut self, command: StorageCommand) -> CliResult<String> {
1235        match command {
1236            StorageCommand::ImportLegacy(command) => self.import_legacy_database(command),
1237        }
1238    }
1239
1240    fn import_legacy_database(&mut self, command: StorageImportLegacyCommand) -> CliResult<String> {
1241        let source = command
1242            .source
1243            .unwrap_or_else(|| self.config.project_dir.join("starweaver.sqlite"));
1244        let workspace = command
1245            .workspace
1246            .unwrap_or_else(|| self.config.workspace_root.clone());
1247        let report = self.store()?.import_legacy_database(&source, &workspace)?;
1248        match command.output {
1249            OutputMode::Text => Ok(format!(
1250                "source={}\nworkspace={}\nsessions_imported={}\nrows_imported={}\nstatus={}\n",
1251                report.source_path.display(),
1252                report.workspace,
1253                report.sessions_imported,
1254                report.rows_imported,
1255                if report.imported {
1256                    "imported"
1257                } else {
1258                    "unchanged"
1259                }
1260            )),
1261            OutputMode::DisplayJsonl | OutputMode::AguiJsonl | OutputMode::Json => Ok(format!(
1262                "{}\n",
1263                serde_json::to_string(&json!({
1264                    "sourcePath": report.source_path,
1265                    "workspace": report.workspace,
1266                    "sessionsImported": report.sessions_imported,
1267                    "rowsImported": report.rows_imported,
1268                    "imported": report.imported,
1269                }))?
1270            )),
1271            OutputMode::Silent => Ok(format!(
1272                "sessions_imported={}\nrows_imported={}\nstatus={}\n",
1273                report.sessions_imported,
1274                report.rows_imported,
1275                if report.imported {
1276                    "imported"
1277                } else {
1278                    "unchanged"
1279                }
1280            )),
1281        }
1282    }
1283
1284    fn session(&mut self, command: SessionCommand) -> CliResult<String> {
1285        match command {
1286            SessionCommand::List(command) => {
1287                let sessions = self.store()?.list_sessions(command.limit)?;
1288                render_sessions(&sessions, command.output)
1289            }
1290            SessionCommand::Search(command) => {
1291                let output = command.output;
1292                let page = self.store()?.search_sessions(search_query(command))?;
1293                render_session_search(&page, output)
1294            }
1295            SessionCommand::Show(command) => {
1296                let session = self.store()?.load_session(&command.session_id)?;
1297                let runs = self.store()?.list_runs(&command.session_id, command.runs)?;
1298                let value = session_value(&session);
1299                render_session_show(&value, &runs, command.output)
1300            }
1301            SessionCommand::Replay(command) => {
1302                let messages = self.store()?.replay_display(
1303                    &command.session_id,
1304                    command.run.as_deref(),
1305                    command.after,
1306                )?;
1307                match command.output {
1308                    OutputMode::Text => Ok(render_display_text(&messages)),
1309                    OutputMode::DisplayJsonl => render_display_jsonl(&messages),
1310                    OutputMode::AguiJsonl => render_agui_jsonl(&messages),
1311                    OutputMode::Json => Ok(format!(
1312                        "{}\n",
1313                        serde_json::to_string(&json!({
1314                            "sessionId": command.session_id,
1315                            "runId": command.run,
1316                            "messages": messages,
1317                            "status": "replayed"
1318                        }))?
1319                    )),
1320                    OutputMode::Silent => Ok(format!(
1321                        "session_id={}\nmessages={}\nstatus=replayed\n",
1322                        command.session_id,
1323                        messages.len()
1324                    )),
1325                }
1326            }
1327            SessionCommand::Delete(command) => {
1328                if !command.yes {
1329                    return Err(CliError::Usage(
1330                        "pass --yes to delete a local session".to_string(),
1331                    ));
1332                }
1333                let session_id = self.store()?.resolve_session_prefix(&command.session_id)?;
1334                let deleted = self.store()?.delete_session(&session_id)?;
1335                let _removed = remove_project_state_if_current_session(&self.config, &session_id)?;
1336                render_session_delete(&session_id, deleted, command.output)
1337            }
1338            SessionCommand::Trim(command) => {
1339                let sessions = if command.all {
1340                    self.store()?.all_session_ids()?
1341                } else if let Some(session_id) = command.session {
1342                    vec![session_id]
1343                } else {
1344                    read_current_session(&self.config)?
1345                        .filter(|session_id| {
1346                            self.store()
1347                                .is_ok_and(|store| store.load_workspace_session(session_id).is_ok())
1348                        })
1349                        .into_iter()
1350                        .collect()
1351                };
1352                let older_than = command
1353                    .older_than
1354                    .as_deref()
1355                    .map(parse_duration)
1356                    .transpose()?;
1357                let report = self.store()?.trim_with_age(
1358                    sessions,
1359                    command.keep_runs,
1360                    older_than,
1361                    command.dry_run,
1362                )?;
1363                render_trim_report(&report, command.output)
1364            }
1365        }
1366    }
1367
1368    fn approval(&mut self, command: ApprovalCommand) -> CliResult<String> {
1369        match command {
1370            ApprovalCommand::List(command) => self.approval_list(&command),
1371            ApprovalCommand::Show { approval_id } => {
1372                let approval = self.store()?.load_approval(&approval_id)?;
1373                Ok(format!("{}\n", serde_json::to_string(&approval)?))
1374            }
1375            ApprovalCommand::Approve(command) => {
1376                self.approval_decision(&command, ApprovalStatus::Approved)
1377            }
1378            ApprovalCommand::Reject(command) => {
1379                self.approval_decision(&command, ApprovalStatus::Denied)
1380            }
1381        }
1382    }
1383
1384    fn approval_list(&mut self, command: &ApprovalListCommand) -> CliResult<String> {
1385        let approvals = self
1386            .store()?
1387            .list_approvals(command.session.as_deref(), command.run.as_deref())?;
1388        render_approvals(&approvals, command.output)
1389    }
1390
1391    fn approval_decision(
1392        &mut self,
1393        command: &ApprovalDecisionCommand,
1394        status: ApprovalStatus,
1395    ) -> CliResult<String> {
1396        let approval =
1397            self.store()?
1398                .decide_approval(&command.approval_id, status, command.reason.clone())?;
1399        match command.output {
1400            OutputMode::Text => Ok(format!(
1401                "approval_id={}\nstatus={}\nrun_id={}\n",
1402                approval.approval_id,
1403                approval_status_name(approval.status),
1404                approval.run_id.as_str()
1405            )),
1406            OutputMode::DisplayJsonl | OutputMode::AguiJsonl | OutputMode::Json => {
1407                Ok(format!("{}\n", serde_json::to_string(&approval)?))
1408            }
1409            OutputMode::Silent => Ok(format!(
1410                "approval_id={}\nstatus={}\n",
1411                approval.approval_id,
1412                approval_status_name(approval.status)
1413            )),
1414        }
1415    }
1416
1417    fn deferred(&mut self, command: DeferredCommand) -> CliResult<String> {
1418        match command {
1419            DeferredCommand::List(command) => self.deferred_list(&command),
1420            DeferredCommand::Show { deferred_id } => {
1421                let deferred = self.store()?.load_deferred_tool(&deferred_id)?;
1422                Ok(format!("{}\n", serde_json::to_string(&deferred)?))
1423            }
1424            DeferredCommand::Complete(command) => self.deferred_complete(&command),
1425            DeferredCommand::Fail(command) => self.deferred_fail(&command),
1426        }
1427    }
1428
1429    fn deferred_list(&mut self, command: &DeferredListCommand) -> CliResult<String> {
1430        let records = self
1431            .store()?
1432            .list_deferred_tools(command.session.as_deref(), command.run.as_deref())?;
1433        render_deferred(&records, command.output)
1434    }
1435
1436    fn deferred_complete(&mut self, command: &DeferredCompleteCommand) -> CliResult<String> {
1437        let value = serde_json::from_str::<Value>(&command.result)
1438            .map_err(|error| CliError::Usage(format!("invalid deferred result JSON: {error}")))?;
1439        let record = self
1440            .store()?
1441            .complete_deferred_tool(&command.deferred_id, value)?;
1442        render_deferred_decision(&record, command.output)
1443    }
1444
1445    fn deferred_fail(&mut self, command: &DeferredFailCommand) -> CliResult<String> {
1446        let record = self
1447            .store()?
1448            .fail_deferred_tool(&command.deferred_id, &command.error)?;
1449        render_deferred_decision(&record, command.output)
1450    }
1451
1452    fn resume(&mut self, command: &ResumeCommand) -> CliResult<String> {
1453        let session_id = self.resolve_session_id(command.session.as_deref())?;
1454        let source_run = self.resolve_resume_run(&session_id, command.run.as_deref())?;
1455        let hitl_resume = source_run.status == RunStatus::Waiting;
1456        let run_command = RunCommand {
1457            prompt: Some(format!(
1458                "{}\n\nResuming from run {} with any persisted approval and deferred-tool decisions.",
1459                command.prompt,
1460                source_run.run_id.as_str()
1461            )),
1462            prompt_parts: Vec::new(),
1463            session: Some(session_id),
1464            continue_session: false,
1465            new_session: false,
1466            run: Some(source_run.run_id.as_str().to_string()),
1467            branch_from: None,
1468            profile: source_run.profile.clone(),
1469            continuation_mode: command.continuation_mode,
1470            output: command.output,
1471            hitl: command.hitl,
1472            goal: None,
1473            worker: None,
1474            worker_label: None,
1475            worktree: None,
1476            worktree_name: None,
1477            branch: None,
1478            session_affinity_id: None,
1479            environment_attachments: Vec::new(),
1480            hitl_resume,
1481        };
1482        self.run_prompt(&run_command)
1483    }
1484
1485    fn resolve_session_id(&mut self, requested: Option<&str>) -> CliResult<String> {
1486        if let Some(session_id) = requested {
1487            self.store()?.load_session(session_id)?;
1488            return Ok(session_id.to_string());
1489        }
1490        if let Some(session_id) = read_current_session(&self.config)?
1491            && self.store()?.load_workspace_session(&session_id).is_ok()
1492        {
1493            return Ok(session_id);
1494        }
1495        self.store()?
1496            .latest_session()?
1497            .map(|session| session.session_id.as_str().to_string())
1498            .ok_or_else(|| CliError::NotFound("session".to_string()))
1499    }
1500
1501    fn resolve_resume_run(
1502        &mut self,
1503        session_id: &str,
1504        requested: Option<&str>,
1505    ) -> CliResult<starweaver_session::RunRecord> {
1506        if let Some(run_id) = requested {
1507            return self.store()?.load_run(session_id, run_id);
1508        }
1509        let session = self.store()?.load_session(session_id)?;
1510        let run_id = session
1511            .active_run_id
1512            .as_ref()
1513            .or(session.head_run_id.as_ref())
1514            .ok_or_else(|| CliError::NotFound("run".to_string()))?;
1515        self.store()?.load_run(session_id, run_id.as_str())
1516    }
1517}
1518
1519impl Drop for CliService {
1520    fn drop(&mut self) {
1521        if let Err(error) = self.shutdown_computer_use() {
1522            // Drop covers early-return and unwind paths that cannot propagate a
1523            // result. Keep cleanup diagnostics off normal command output.
1524            eprintln!("starweaver-cli: {error}");
1525        }
1526    }
1527}
1528
1529fn continuation_drift_prefix(continuation: Option<&ContinuationMaterialization>) -> String {
1530    continuation
1531        .filter(|item| !item.drift.is_empty())
1532        .map_or_else(String::new, |item| {
1533            format!(
1534                "materialization_drift mode={} fields={}\n",
1535                item.mode.as_str(),
1536                item.drift_summary()
1537            )
1538        })
1539}
1540
1541fn phase_aware_hitl_start_error(
1542    config: CliConfig,
1543    lease: RunAdmissionLease,
1544    source_run_id: RunId,
1545    claim_id: String,
1546    start_error: CliError,
1547) -> CliError {
1548    // A response error is not proof that the state transition did not commit. Abort is
1549    // phase-aware: an admitted claim safely terminalizes only this replacement, while a started
1550    // claim is left for the caller's fenced related-run evidence.
1551    match abort_admitted_hitl_resume_operation(
1552        config,
1553        lease,
1554        source_run_id,
1555        claim_id,
1556        "HITL continuation failed before effect start",
1557    ) {
1558        Ok(HitlResumeAbortOutcome::AbortedBeforeEffect | HitlResumeAbortOutcome::EffectStarted) => {
1559            start_error
1560        }
1561        Err(abort_error) => CliError::Storage(format!(
1562            "{start_error}; failed to determine HITL effect phase: {abort_error}"
1563        )),
1564    }
1565}
1566
1567fn abort_admitted_hitl_resume_operation(
1568    config: CliConfig,
1569    lease: RunAdmissionLease,
1570    source_run_id: RunId,
1571    claim_id: String,
1572    output_preview: &'static str,
1573) -> CliResult<HitlResumeAbortOutcome> {
1574    thread::spawn(move || {
1575        let store =
1576            LocalSessionStore::new(config).map_err(|error| CliError::Storage(error.to_string()))?;
1577        let runtime = tokio::runtime::Builder::new_current_thread()
1578            .enable_all()
1579            .build()
1580            .map_err(|error| CliError::Run(error.to_string()))?;
1581        runtime
1582            .block_on(store.abort_admitted_hitl_resume(
1583                &lease,
1584                &source_run_id,
1585                &claim_id,
1586                output_preview,
1587            ))
1588            .map_err(|error| CliError::Storage(error.to_string()))
1589    })
1590    .join()
1591    .map_err(|_| CliError::Run("HITL resume abort worker panicked".to_string()))?
1592}
1593
1594fn run_hitl_resume_claim_operation(
1595    config: CliConfig,
1596    operation: HitlResumeClaimOperation,
1597) -> CliResult<()> {
1598    thread::spawn(move || {
1599        let store =
1600            LocalSessionStore::new(config).map_err(|error| CliError::Storage(error.to_string()))?;
1601        let runtime = tokio::runtime::Builder::new_current_thread()
1602            .enable_all()
1603            .build()
1604            .map_err(|error| CliError::Run(error.to_string()))?;
1605        runtime
1606            .block_on(async {
1607                match operation {
1608                    HitlResumeClaimOperation::Claim(claim) => {
1609                        store
1610                            .reconcile_expired_run_admissions(
1611                                starweaver_session::LOCAL_SESSION_NAMESPACE,
1612                                Utc::now(),
1613                            )
1614                            .await?;
1615                        store.claim_hitl_resume(claim).await
1616                    }
1617                    HitlResumeClaimOperation::StartEffect {
1618                        lease,
1619                        source_run_id,
1620                        claim_id,
1621                    } => {
1622                        store
1623                            .start_hitl_resume_effect(&lease, &source_run_id, &claim_id)
1624                            .await
1625                    }
1626                    HitlResumeClaimOperation::Release {
1627                        session_id,
1628                        run_id,
1629                        claim_id,
1630                    } => {
1631                        store
1632                            .release_hitl_resume_claim(&session_id, &run_id, &claim_id)
1633                            .await
1634                    }
1635                }
1636            })
1637            .map_err(|error| CliError::Storage(error.to_string()))
1638    })
1639    .join()
1640    .map_err(|_| CliError::Run("HITL resume claim worker panicked".to_string()))?
1641}
1642
1643#[allow(dead_code)]
1644fn start_oauth_refresh_guard(config: &CliConfig) -> CliResult<Option<OAuthRefreshGuard>> {
1645    if !config.oauth_refresh.enabled {
1646        return Ok(None);
1647    }
1648    let models = list_profiles(config)
1649        .into_iter()
1650        .map(|profile| profile.model_id)
1651        .collect::<Vec<_>>();
1652    if config.oauth_refresh.interval_seconds == 0 {
1653        return Err(CliError::Usage(
1654            "invalid oauth_refresh.interval_seconds: value must be positive".to_string(),
1655        ));
1656    }
1657    if config.oauth_refresh.failure_retry_seconds == 0 {
1658        return Err(CliError::Usage(
1659            "invalid oauth_refresh.failure_retry_seconds: value must be positive".to_string(),
1660        ));
1661    }
1662    let mut supervisor = create_oauth_refresh_supervisor_for_models_with_options(
1663        models.iter().map(String::as_str),
1664        Duration::from_secs(config.oauth_refresh.interval_seconds),
1665        Duration::from_secs(config.oauth_refresh.failure_retry_seconds),
1666        config.oauth_refresh.refresh_on_startup,
1667    )
1668    .map_err(oauth_cli_error)?;
1669    let Some(mut supervisor) = supervisor.take() else {
1670        return Ok(None);
1671    };
1672    let (stop_sender, stop_receiver) = mpsc::channel::<()>();
1673    let handle = thread::Builder::new()
1674        .name("starweaver-oauth-refresh".to_string())
1675        .spawn(move || {
1676            let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
1677                .enable_all()
1678                .build()
1679            else {
1680                return;
1681            };
1682            runtime.block_on(async move {
1683                supervisor.start().await;
1684                let _ = tokio::task::spawn_blocking(move || stop_receiver.recv()).await;
1685                supervisor.shutdown().await;
1686            });
1687        })
1688        .map_err(|error| CliError::Run(error.to_string()))?;
1689    Ok(Some(OAuthRefreshGuard {
1690        stop_sender,
1691        handle: Some(handle),
1692    }))
1693}
1694
1695fn render_json_lines<T: serde::Serialize>(items: &[T]) -> CliResult<String> {
1696    items
1697        .iter()
1698        .map(|item| serde_json::to_string(item).map(|line| format!("{line}\n")))
1699        .collect::<Result<String, _>>()
1700        .map_err(CliError::from)
1701}
1702
1703const fn run_status_name(status: starweaver_session::RunStatus) -> &'static str {
1704    status.as_str()
1705}
1706
1707fn parse_duration(value: &str) -> CliResult<chrono::Duration> {
1708    let trimmed = value.trim();
1709    if trimmed.is_empty() {
1710        return Err(CliError::Usage("duration cannot be empty".to_string()));
1711    }
1712    let (number, unit) = trimmed.split_at(
1713        trimmed
1714            .find(|ch: char| !ch.is_ascii_digit())
1715            .unwrap_or(trimmed.len()),
1716    );
1717    let amount = number
1718        .parse::<i64>()
1719        .map_err(|error| CliError::Usage(error.to_string()))?;
1720    let duration = match unit {
1721        "" | "s" | "sec" | "secs" => chrono::Duration::seconds(amount),
1722        "m" | "min" | "mins" => chrono::Duration::minutes(amount),
1723        "h" | "hr" | "hrs" => chrono::Duration::hours(amount),
1724        "d" | "day" | "days" => chrono::Duration::days(amount),
1725        other => return Err(CliError::Usage(format!("unknown duration unit: {other}"))),
1726    };
1727    Ok(duration)
1728}
1729
1730#[cfg(test)]
1731mod tests;