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