1#![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('&', "&")
244 .replace('<', "<")
245 .replace('>', ">")
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, ¤t)
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, ¤t)?;
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
418pub 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 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 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 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 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 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 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 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 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 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;