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