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