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