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