1#![allow(clippy::redundant_pub_crate)]
3
4use std::{collections::BTreeSet, fs, path::Path, sync::mpsc, thread, time::Duration};
5
6use chrono::Utc;
7use serde_json::{Value, json};
8use starweaver_agent::ResumableState;
9use starweaver_core::{RunId, SessionId, sdk_name};
10use starweaver_oauth_provider::create_oauth_refresh_supervisor_for_models_with_options;
11use starweaver_runtime::AgentStreamRecord;
12use starweaver_session::{
13 ApprovalStatus, HitlResumeClaim, RunRecord, RunStatus, SessionSearchFilter,
14 SessionSearchGranularity, SessionSearchQuery, SessionSearchSource, SessionStatus, SessionStore,
15};
16use starweaver_stream::DisplayMessage;
17
18use crate::{
19 CliError, CliResult,
20 args::{
21 ApprovalCommand, ApprovalDecisionCommand, ApprovalListCommand, Cli, CliCommand,
22 DeferredCommand, DeferredCompleteCommand, DeferredFailCommand, DeferredListCommand,
23 OutputMode, ResumeCommand, RunCommand, SessionCommand, SessionSearchCommand,
24 SessionSearchGranularityArg, SessionSearchSourceArg, SessionSearchStatusArg, TuiCommand,
25 },
26 config::{
27 CliConfig, read_current_session, read_last_retention_maintenance, write_current_session,
28 write_last_retention_maintenance,
29 },
30 environment::{
31 ResolvedEnvironment, resolve_environment_for_session_with_attachments,
32 validate_environment_config,
33 },
34 local_store::{
35 HITL_RESUME_CLAIM_ID_METADATA_KEY, HITL_RESUME_PREFLIGHT_SOURCE_RUN_ID_METADATA_KEY,
36 HITL_RESUME_SOURCE_RUN_ID_METADATA_KEY, LocalSessionStore, LocalStore,
37 },
38 profiles::{ResolvedProfile, list_profiles, resolve_profile},
39 prompt_input::PromptInput,
40 runner::{
41 CliAgentExecutionHost, CliRunPolicy, CliSteeringChannel, execute_agent_session_with_host,
42 failed_display_message,
43 },
44 slash_commands::{ExpandedExplicitSkills, expand_explicit_skills, expand_slash_command},
45};
46
47mod auth;
48mod catalog;
49mod rendering;
50mod setup;
51mod tui;
52mod worktree;
53
54use auth::oauth_cli_error;
55use rendering::{
56 approval_status_name, render_agui_jsonl, render_approvals, render_completion, render_deferred,
57 render_deferred_decision, render_display_jsonl, render_display_text, render_prompt_run_json,
58 render_session_delete, render_session_search, render_session_show, render_sessions,
59 render_trim_report, session_value,
60};
61use setup::remove_file_if_exists;
62#[cfg(test)]
63use tui::model_choices;
64use worktree::apply_starweaver_run_metadata;
65
66pub(super) struct PromptRunExecution {
67 pub(super) session_id: String,
68 pub(super) run_id: String,
69 pub(super) status: String,
70 pub(super) output_mode: OutputMode,
71 pub(super) messages: Vec<DisplayMessage>,
72}
73
74pub(super) struct PreparedPromptRun {
75 pub(super) session_id: String,
76 pub(super) run_id: String,
77 pub(super) output_mode: OutputMode,
78 pub(super) run: RunRecord,
79 run_input: PromptInput,
80 resolved_profile: ResolvedProfile,
81 pub(super) environment: ResolvedEnvironment,
82 restore_state: Option<ResumableState>,
83 policy: CliRunPolicy,
84 execution_host: CliAgentExecutionHost,
85 hitl_resume_claim: Option<HitlResumeClaim>,
86}
87
88impl PreparedPromptRun {
89 pub(super) fn set_execution_host(&mut self, execution_host: CliAgentExecutionHost) {
90 self.execution_host = execution_host;
91 }
92}
93
94pub(super) struct ExecutedPromptRun {
95 run: RunRecord,
96 output_mode: OutputMode,
97 execution: crate::runner::CliRunExecution,
98}
99
100enum HitlResumeClaimOperation {
101 Claim(HitlResumeClaim),
102 Start {
103 session_id: SessionId,
104 run_id: RunId,
105 claim_id: String,
106 },
107 Release {
108 session_id: SessionId,
109 run_id: RunId,
110 claim_id: String,
111 },
112}
113
114const PROJECT_GUIDANCE_TAG: &str = "project-guidance";
115const USER_RULES_TAG: &str = "user-rules";
116
117fn restore_requires_hitl_claim(status: RunStatus, hitl_resume: bool, branch_from: bool) -> bool {
118 status == RunStatus::Waiting && !hitl_resume && !branch_from
119}
120
121fn search_query(command: SessionSearchCommand) -> SessionSearchQuery {
122 let session_statuses = command
123 .status
124 .map(|status| match status {
125 SessionSearchStatusArg::Active => SessionStatus::Active,
126 SessionSearchStatusArg::Archived => SessionStatus::Archived,
127 SessionSearchStatusArg::Failed => SessionStatus::Failed,
128 })
129 .into_iter()
130 .collect();
131 let sources = command
132 .sources
133 .into_iter()
134 .map(|source| match source {
135 SessionSearchSourceArg::SessionMetadata => SessionSearchSource::SessionMetadata,
136 SessionSearchSourceArg::RunInput => SessionSearchSource::RunInput,
137 SessionSearchSourceArg::RunOutputPreview => SessionSearchSource::RunOutputPreview,
138 SessionSearchSourceArg::DisplayMessage => SessionSearchSource::DisplayMessage,
139 })
140 .collect::<BTreeSet<_>>();
141 let granularity = match command.granularity {
142 SessionSearchGranularityArg::Session => SessionSearchGranularity::Session,
143 SessionSearchGranularityArg::Run => SessionSearchGranularity::Run,
144 SessionSearchGranularityArg::Occurrence => SessionSearchGranularity::Occurrence,
145 };
146 SessionSearchQuery {
147 text: command.text,
148 filter: SessionSearchFilter {
149 session_statuses,
150 profile: command.profile,
151 workspace: command.workspace,
152 ..SessionSearchFilter::default()
153 },
154 sources,
155 granularity,
156 limit: command.limit,
157 cursor: command.cursor,
158 ..SessionSearchQuery::default()
159 }
160}
161
162fn append_guidance_files(input: &mut PromptInput, config: &CliConfig) {
163 if let Some(project_guidance) = load_project_guidance(&config.workspace_root) {
164 input.push_guidance_text_part(project_guidance);
165 }
166 if let Some(user_rules) = load_user_rules(&config.global_dir) {
167 input.push_guidance_text_part(user_rules);
168 }
169}
170
171fn append_explicit_skill_guidance(
172 input: &mut PromptInput,
173 explicit_skills: Option<&ExpandedExplicitSkills>,
174) {
175 let Some(explicit_skills) = explicit_skills else {
176 return;
177 };
178 let names = explicit_skills
179 .skills
180 .iter()
181 .map(|skill| skill.package.name.as_str())
182 .collect::<Vec<_>>()
183 .join(", ");
184 input.push_guidance_text_part(format!(
185 "<explicit-skill-composition>\nThe user explicitly activated these skills in priority order: {names}. Apply all compatible instructions. Treat the first skill as the primary workflow and later skills as supporting workflows. The current user request overrides skill defaults. If selected skills conflict irreconcilably, ask the user for clarification.\n</explicit-skill-composition>"
186 ));
187 for skill in &explicit_skills.skills {
188 let package = &skill.package;
189 let Some(body) = package.body.as_deref() else {
190 continue;
191 };
192 input.push_guidance_text_part(format!(
193 "<explicitly-activated-skill>\n<name>{}</name>\n<path>{}</path>\n<instructions>\n{}\n</instructions>\n</explicitly-activated-skill>",
194 escape_xml_text(&package.name),
195 escape_xml_text(&package.path),
196 body
197 ));
198 }
199}
200
201fn escape_xml_text(value: &str) -> String {
202 value
203 .replace('&', "&")
204 .replace('<', "<")
205 .replace('>', ">")
206}
207
208fn load_project_guidance(workspace_root: &Path) -> Option<String> {
209 let path = workspace_root.join("AGENTS.md");
210 let content = read_non_empty_utf8_file(&path)?;
211 Some(format!(
212 "<{PROJECT_GUIDANCE_TAG} name=AGENTS.md>\n{content}\n</{PROJECT_GUIDANCE_TAG}>"
213 ))
214}
215
216fn load_user_rules(global_dir: &Path) -> Option<String> {
217 let path = global_dir.join("RULES.md");
218 let content = read_non_empty_utf8_file(&path)?;
219 Some(format!(
220 "<{USER_RULES_TAG} location={}>\n{content}\n</{USER_RULES_TAG}>",
221 path_absolute_posix(&path)
222 ))
223}
224
225fn read_non_empty_utf8_file(path: &Path) -> Option<String> {
226 let content = fs::read_to_string(path).ok()?;
227 (!content.trim().is_empty()).then_some(content)
228}
229
230fn path_absolute_posix(path: &Path) -> String {
231 path.canonicalize()
232 .unwrap_or_else(|_| path.to_path_buf())
233 .display()
234 .to_string()
235 .replace('\\', "/")
236}
237
238#[allow(dead_code)]
239struct OAuthRefreshGuard {
240 stop_sender: mpsc::Sender<()>,
241 handle: Option<thread::JoinHandle<()>>,
242}
243
244impl Drop for OAuthRefreshGuard {
245 fn drop(&mut self) {
246 let _ = self.stop_sender.send(());
247 if let Some(handle) = self.handle.take() {
248 let _ = handle.join();
249 }
250 }
251}
252
253pub struct CliService {
255 config: CliConfig,
256 store: Option<LocalStore>,
257}
258
259impl CliService {
260 pub const fn open(config: CliConfig) -> CliResult<Self> {
262 Ok(Self {
263 config,
264 store: None,
265 })
266 }
267
268 fn store(&mut self) -> CliResult<&mut LocalStore> {
269 if self.store.is_none() {
270 self.store = Some(LocalStore::open(&self.config)?);
271 }
272 self.store
273 .as_mut()
274 .ok_or_else(|| CliError::Storage("store initialization failed".to_string()))
275 }
276
277 pub fn execute(mut self, cli: Cli) -> CliResult<String> {
279 if let Some(prompt) = cli.prompt.clone() {
280 let command = RunCommand {
281 prompt: Some(prompt),
282 prompt_parts: Vec::new(),
283 session: cli.session.clone(),
284 continue_session: cli.continue_session,
285 new_session: cli.new_session,
286 run: cli.run.clone(),
287 branch_from: cli.branch_from.clone(),
288 profile: cli.profile.clone(),
289 output: cli.output,
290 hitl: cli.hitl,
291 goal: None,
292 worker: cli.worker.clone(),
293 worker_label: cli.worker_label.clone(),
294 worktree: cli.worktree.clone(),
295 worktree_name: cli.worktree_name.clone(),
296 branch: cli.branch,
297 session_affinity_id: None,
298 environment_attachments: Vec::new(),
299 hitl_resume: false,
300 };
301 return self.run_prompt(&command);
302 }
303 let default_command = CliCommand::Tui(TuiCommand {
304 session: cli.session.clone(),
305 run: cli.run.clone(),
306 after: None,
307 interactive: false,
308 snapshot: false,
309 output: OutputMode::Text,
310 render_mode: None,
311 });
312 match cli.command.unwrap_or(default_command) {
313 CliCommand::Version => Ok(format!("{}\n", sdk_name())),
314 CliCommand::Diagnostics => Ok(self.diagnostics()?),
315 CliCommand::ReplayCheck => {
316 Ok("run `make replay-check` from the repository root\n".to_string())
317 }
318 CliCommand::Update(command) => Self::update(&command),
319 CliCommand::Run(command) => self.run_prompt(&command),
320 CliCommand::Session { command } => self.session(command),
321 CliCommand::Profile { command } => self.profile(command),
322 CliCommand::Setup(command) => self.setup(&command),
323 CliCommand::Auth { command } => Self::auth(command),
324 CliCommand::Skill { command } => self.skills(command),
325 CliCommand::Subagent { command } => self.subagents(command),
326 CliCommand::Mcp { command } => self.mcp(command),
327 CliCommand::Tools { command } => self.tools(&command),
328 CliCommand::Tui(command) => self.tui(&command),
329 CliCommand::Approval { command } => self.approval(command),
330 CliCommand::Deferred { command } => self.deferred(command),
331 CliCommand::Resume(command) => self.resume(&command),
332 CliCommand::Reset(command) => self.reset(&command),
333 CliCommand::Config { command } => self.config(command),
334 CliCommand::Completion { shell } => render_completion(shell),
335 }
336 }
337
338 pub(crate) fn run_prompt(&mut self, command: &RunCommand) -> CliResult<String> {
339 let execution = self.execute_prompt_run(command, None)?;
340 match execution.output_mode {
341 OutputMode::Text => Ok(render_display_text(&execution.messages)),
342 OutputMode::DisplayJsonl => render_display_jsonl(&execution.messages),
343 OutputMode::AguiJsonl => render_agui_jsonl(&execution.messages),
344 OutputMode::Json => render_prompt_run_json(&execution),
345 OutputMode::Silent => Ok(format!(
346 "session_id={}\nrun_id={}\nstatus={}\n",
347 execution.session_id, execution.run_id, execution.status
348 )),
349 }
350 }
351
352 fn execute_prompt_run(
353 &mut self,
354 command: &RunCommand,
355 stream_sender: Option<mpsc::SyncSender<AgentStreamRecord>>,
356 ) -> CliResult<PromptRunExecution> {
357 self.execute_prompt_run_with_channels(command, None, stream_sender, None, None)
358 }
359
360 #[allow(clippy::too_many_lines)]
361 fn execute_prompt_run_with_channels(
362 &mut self,
363 command: &RunCommand,
364 prompt_input: Option<PromptInput>,
365 stream_sender: Option<mpsc::SyncSender<AgentStreamRecord>>,
366 steering_channel: Option<CliSteeringChannel>,
367 cancel_receiver: Option<mpsc::Receiver<()>>,
368 ) -> CliResult<PromptRunExecution> {
369 let mut prepared = self.prepare_prompt_run(command, prompt_input)?;
370 let run_on_error = prepared.run.clone();
371 if let Err(error) = self.start_prepared_hitl_resume(&mut prepared) {
372 self.fail_prepared_prompt_run(run_on_error, &error)?;
373 return Err(error);
374 }
375 let run_on_error = prepared.run.clone();
376 let executed = match Self::run_prepared_prompt(
377 prepared,
378 stream_sender,
379 steering_channel,
380 cancel_receiver,
381 ) {
382 Ok(executed) => executed,
383 Err(error) => {
384 self.fail_prepared_prompt_run(run_on_error, &error)?;
385 return Err(error);
386 }
387 };
388 self.complete_prompt_run(executed)
389 }
390
391 fn reject_ordinary_admission_during_waiting_continuation(
392 &mut self,
393 session_id: &str,
394 hitl_resume: bool,
395 ) -> CliResult<()> {
396 if hitl_resume {
397 return Ok(());
398 }
399 let session = self.store()?.load_session(session_id)?;
400 let runs = self.store()?.list_run_records(session_id)?;
401 let active_run_id = session.active_run_id.as_ref();
402 for run in runs.iter().filter(|run| {
403 active_run_id == Some(&run.run_id)
404 || (run.status.is_active() && run.status != RunStatus::Waiting)
405 }) {
406 let waiting_source_run_id = if run.status == RunStatus::Waiting {
407 Some(&run.run_id)
408 } else {
409 run.restore_from_run_id.as_ref().filter(|source_run_id| {
410 runs.iter().any(|source| {
411 source.run_id.as_str() == source_run_id.as_str()
412 && source.status == RunStatus::Waiting
413 })
414 })
415 };
416 if let Some(waiting_source_run_id) = waiting_source_run_id {
417 return Err(CliError::Run(format!(
418 "run {} is continuing waiting run {}; wait for it to finish before starting another prompt",
419 run.run_id.as_str(),
420 waiting_source_run_id.as_str()
421 )));
422 }
423 }
424 Ok(())
425 }
426
427 #[allow(clippy::too_many_lines)]
428 pub(super) fn prepare_prompt_run(
429 &mut self,
430 command: &RunCommand,
431 prompt_input: Option<PromptInput>,
432 ) -> CliResult<PreparedPromptRun> {
433 let input =
434 prompt_input.map_or_else(|| command.prompt_text().map(PromptInput::text), Ok)?;
435 let raw_prompt = input.text.clone();
436 let worktree = self.resolve_worktree(command)?;
437 let selected_profile = command
438 .profile
439 .as_deref()
440 .unwrap_or(&self.config.default_profile);
441 let resolved_profile = resolve_profile(&self.config, Some(selected_profile))?;
442 let slash_expansion = expand_slash_command(&self.config.slash_commands, &raw_prompt);
443 let explicit_skills = slash_expansion
444 .is_none()
445 .then(|| expand_explicit_skills(&resolved_profile.skills, &raw_prompt))
446 .flatten();
447 let prompt = slash_expansion.as_ref().map_or_else(
448 || {
449 explicit_skills
450 .as_ref()
451 .map_or_else(|| raw_prompt.clone(), |expanded| expanded.prompt.clone())
452 },
453 |expanded| expanded.prompt.clone(),
454 );
455 let mut run_input = PromptInput {
456 text: prompt.clone(),
457 attachments: input.attachments,
458 extra_text_parts: input.extra_text_parts,
459 guidance_text_parts: input.guidance_text_parts,
460 };
461 let mut run_config = self.config.clone();
462 if let Some(worktree) = worktree.as_ref() {
463 run_config.workspace_root.clone_from(&worktree.path);
464 }
465 validate_environment_config(&run_config)?;
466 append_guidance_files(&mut run_input, &run_config);
467 append_explicit_skill_guidance(&mut run_input, explicit_skills.as_ref());
468 let (session_id, created) = self.resolve_session(command, &resolved_profile.name)?;
469 if command.run.is_some() || command.branch_from.is_some() {
470 self.reject_ordinary_admission_during_waiting_continuation(
471 &session_id,
472 command.hitl_resume,
473 )?;
474 }
475 let environment = resolve_environment_for_session_with_attachments(
476 &run_config,
477 &session_id,
478 &command.environment_attachments,
479 )?;
480 let hitl_resume_source_run_id = command
481 .hitl_resume
482 .then(|| {
483 command
484 .run
485 .as_deref()
486 .ok_or_else(|| {
487 CliError::Usage("HITL continuation requires a source run id".to_string())
488 })
489 .map(RunId::from_string)
490 })
491 .transpose()?;
492 let mut restore_from = command.run.clone().or_else(|| command.branch_from.clone());
493 if restore_from.is_none() && !created {
494 restore_from = self
495 .store()?
496 .load_session(&session_id)
497 .ok()
498 .and_then(|session| {
499 session
500 .active_run_id
501 .or(session.head_run_id)
502 .or(session.head_success_run_id)
503 .map(|run| run.as_str().to_string())
504 });
505 }
506 let mut waiting_restore_without_claim = None;
507 if let Some(source_run_id) = restore_from.as_deref() {
508 let source = self.store()?.load_run(&session_id, source_run_id)?;
509 if restore_requires_hitl_claim(
510 source.status,
511 command.hitl_resume,
512 command.branch_from.is_some(),
513 ) {
514 waiting_restore_without_claim = Some(source_run_id.to_string());
515 }
516 if source.status.is_active()
517 && source.status != RunStatus::Waiting
518 && command.branch_from.is_none()
519 && let Some(waiting_source_run_id) = source.restore_from_run_id.as_ref()
520 && self
521 .store()?
522 .load_run(&session_id, waiting_source_run_id.as_str())?
523 .status
524 == RunStatus::Waiting
525 {
526 return Err(CliError::Run(format!(
527 "run {source_run_id} is continuing waiting run {}; wait for it to finish before starting another prompt",
528 waiting_source_run_id.as_str()
529 )));
530 }
531 }
532 let restore_state = self
533 .store()?
534 .load_restore_state(&session_id, restore_from.as_deref())?;
535 if let Some(source_run_id) = waiting_restore_without_claim {
536 return Err(CliError::Run(format!(
537 "run {source_run_id} is waiting and requires an explicit HITL resume"
538 )));
539 }
540 write_current_session(&self.config, &session_id)?;
541 self.reject_ordinary_admission_during_waiting_continuation(
542 &session_id,
543 command.hitl_resume,
544 )?;
545 let hitl_resume_claim = hitl_resume_source_run_id.map(|source_run_id| {
546 HitlResumeClaim::new(
547 format!("cli-hitl-resume-{}", uuid::Uuid::new_v4()),
548 SessionId::from_string(&session_id),
549 source_run_id,
550 Utc::now(),
551 )
552 });
553 if let Some(claim) = hitl_resume_claim.clone() {
554 run_hitl_resume_claim_operation(
555 self.config.clone(),
556 HitlResumeClaimOperation::Claim(claim),
557 )?;
558 }
559 let mut run = match self.store()?.append_run(
560 &session_id,
561 prompt,
562 restore_from,
563 &resolved_profile.name,
564 ) {
565 Ok(run) => run,
566 Err(error) => {
567 if let Some(claim) = hitl_resume_claim.as_ref() {
568 let release = run_hitl_resume_claim_operation(
569 self.config.clone(),
570 HitlResumeClaimOperation::Release {
571 session_id: claim.session_id.clone(),
572 run_id: claim.run_id.clone(),
573 claim_id: claim.claim_id.clone(),
574 },
575 );
576 if let Err(release_error) = release {
577 return Err(CliError::Storage(format!(
578 "{error}; failed to release preflight HITL claim: {release_error}"
579 )));
580 }
581 }
582 return Err(error);
583 }
584 };
585 if let Some(claim) = hitl_resume_claim.as_ref() {
586 run.metadata.insert(
587 HITL_RESUME_PREFLIGHT_SOURCE_RUN_ID_METADATA_KEY.to_string(),
588 json!(claim.run_id.as_str()),
589 );
590 }
591 apply_starweaver_run_metadata(
592 &mut run,
593 command,
594 worktree.as_ref(),
595 slash_expansion.as_ref(),
596 );
597 if let Some(explicit_skills) = explicit_skills.as_ref() {
598 run.metadata.insert(
599 "cli.skills.activated".to_string(),
600 json!(
601 explicit_skills
602 .skills
603 .iter()
604 .map(|skill| json!({
605 "name": skill.package.name,
606 "invoked": skill.invoked_name,
607 "description": skill.package.description,
608 "path": skill.package.path,
609 "metadata": skill.package.metadata,
610 }))
611 .collect::<Vec<_>>()
612 ),
613 );
614 }
615 if let Some(session_affinity_id) = command.session_affinity_id.as_deref() {
616 run.metadata.insert(
617 "starweaver.session_affinity_id".to_string(),
618 json!(session_affinity_id),
619 );
620 }
621 if !command.environment_attachments.is_empty() {
622 run.metadata.insert(
623 "starweaver.environment_attachments".to_string(),
624 json!(command.environment_attachments),
625 );
626 run.metadata.insert(
627 "starweaver.environment_attachment_ids".to_string(),
628 json!(
629 command
630 .environment_attachments
631 .iter()
632 .map(|attachment| attachment.id.as_str())
633 .collect::<Vec<_>>()
634 ),
635 );
636 }
637 let hitl = command.hitl.unwrap_or(self.config.default_hitl);
638 let goal = command
639 .goal
640 .as_ref()
641 .map(|goal| crate::runner::CliGoalRunPolicy {
642 objective: goal.objective.clone(),
643 max_iterations: goal.max_iterations.max(1),
644 });
645 let output_mode = command.output.unwrap_or(self.config.default_output);
646 Ok(PreparedPromptRun {
647 session_id,
648 run_id: run.run_id.as_str().to_string(),
649 output_mode,
650 run,
651 run_input,
652 resolved_profile,
653 environment,
654 restore_state,
655 policy: CliRunPolicy { hitl, goal },
656 execution_host: if command.worker.is_some() || command.worker_label.is_some() {
657 CliAgentExecutionHost::disabled()
658 } else {
659 CliAgentExecutionHost::blocking()
660 },
661 hitl_resume_claim,
662 })
663 }
664
665 pub(super) fn start_prepared_hitl_resume(
666 &self,
667 prepared: &mut PreparedPromptRun,
668 ) -> CliResult<()> {
669 let Some(claim) = prepared.hitl_resume_claim.take() else {
670 return Ok(());
671 };
672 let session_id = claim.session_id;
673 let source_run_id = claim.run_id;
674 let claim_id = claim.claim_id;
675 if let Err(error) = run_hitl_resume_claim_operation(
676 self.config.clone(),
677 HitlResumeClaimOperation::Start {
678 session_id: session_id.clone(),
679 run_id: source_run_id.clone(),
680 claim_id: claim_id.clone(),
681 },
682 ) {
683 let release = run_hitl_resume_claim_operation(
684 self.config.clone(),
685 HitlResumeClaimOperation::Release {
686 session_id,
687 run_id: source_run_id,
688 claim_id,
689 },
690 );
691 return match release {
692 Ok(()) => Err(error),
693 Err(release_error) => Err(CliError::Storage(format!(
694 "{error}; failed to release preflight HITL claim: {release_error}"
695 ))),
696 };
697 }
698 prepared.run.metadata.insert(
699 HITL_RESUME_CLAIM_ID_METADATA_KEY.to_string(),
700 json!(claim_id),
701 );
702 prepared.run.metadata.insert(
703 HITL_RESUME_SOURCE_RUN_ID_METADATA_KEY.to_string(),
704 json!(source_run_id.as_str()),
705 );
706 Ok(())
707 }
708
709 pub(super) fn run_prepared_prompt(
710 prepared: PreparedPromptRun,
711 stream_sender: Option<mpsc::SyncSender<AgentStreamRecord>>,
712 steering_channel: Option<CliSteeringChannel>,
713 cancel_receiver: Option<mpsc::Receiver<()>>,
714 ) -> CliResult<ExecutedPromptRun> {
715 let PreparedPromptRun {
716 output_mode,
717 run,
718 run_input,
719 resolved_profile,
720 environment,
721 restore_state,
722 policy,
723 execution_host,
724 ..
725 } = prepared;
726 let result = execute_agent_session_with_host(
727 run_input,
728 &run,
729 &resolved_profile,
730 &environment.provider,
731 environment.process_provider.as_ref(),
732 restore_state,
733 &policy,
734 stream_sender,
735 steering_channel,
736 cancel_receiver,
737 execution_host,
738 );
739 result.map(|execution| ExecutedPromptRun {
740 run,
741 output_mode,
742 execution,
743 })
744 }
745
746 pub(super) fn fail_prepared_prompt_run(
747 &mut self,
748 mut run: RunRecord,
749 error: &CliError,
750 ) -> CliResult<()> {
751 let messages = failed_display_message(&run, &error.to_string());
752 self.store()?
753 .fail_run_with_messages(&mut run, error.to_string(), &messages)
754 }
755
756 pub(super) fn complete_prompt_run(
757 &mut self,
758 executed: ExecutedPromptRun,
759 ) -> CliResult<PromptRunExecution> {
760 let ExecutedPromptRun {
761 mut run,
762 output_mode,
763 execution,
764 } = executed;
765 let execution_failed = execution.artifacts.status == RunStatus::Failed;
766 let output = execution.output;
767 let messages = self
768 .store()?
769 .complete_run(&mut run, output.clone(), execution.artifacts)?;
770 if execution_failed && matches!(output_mode, OutputMode::Text | OutputMode::Silent) {
771 return Err(CliError::Run(output));
772 }
773 self.run_retention_maintenance(run.session_id.as_str())?;
774 Ok(PromptRunExecution {
775 session_id: run.session_id.as_str().to_string(),
776 run_id: run.run_id.as_str().to_string(),
777 status: run_status_name(run.status).to_string(),
778 output_mode,
779 messages,
780 })
781 }
782
783 fn run_retention_maintenance(&mut self, current_session_id: &str) -> CliResult<()> {
784 if !self.config.auto_trim {
785 return Ok(());
786 }
787 let current_keep_runs = self.config.current_session_keep_recent_runs;
788 self.store()?.trim(
789 vec![current_session_id.to_string()],
790 current_keep_runs,
791 false,
792 )?;
793 if !self.should_run_all_sessions_retention()? {
794 return Ok(());
795 }
796 let sessions = self.store()?.all_session_ids()?;
797 let all_sessions_keep_runs = self.config.all_sessions_keep_recent_runs;
798 let older_than = chrono::Duration::days(
799 i64::try_from(self.config.all_sessions_keep_days)
800 .unwrap_or(i64::MAX)
801 .max(0),
802 );
803 self.store()?
804 .trim_with_age(sessions, all_sessions_keep_runs, Some(older_than), false)?;
805 write_last_retention_maintenance(&self.config, chrono::Utc::now())?;
806 Ok(())
807 }
808
809 fn should_run_all_sessions_retention(&self) -> CliResult<bool> {
810 if self.config.all_sessions_keep_days == 0 || self.config.all_sessions_interval_hours == 0 {
811 return Ok(false);
812 }
813 let Some(last_run) = read_last_retention_maintenance(&self.config)? else {
814 return Ok(true);
815 };
816 let elapsed = chrono::Utc::now().signed_duration_since(last_run);
817 Ok(elapsed
818 >= chrono::Duration::hours(
819 i64::try_from(self.config.all_sessions_interval_hours)
820 .unwrap_or(i64::MAX)
821 .max(0),
822 ))
823 }
824
825 fn resolve_session(
826 &mut self,
827 command: &RunCommand,
828 profile: &str,
829 ) -> CliResult<(String, bool)> {
830 if command.new_session {
831 let session = self
832 .store()?
833 .create_session(profile, Some("CLI session".to_string()))?;
834 return Ok((session.session_id.as_str().to_string(), true));
835 }
836 if let Some(session_id) = command.session.as_ref() {
837 self.store()?.load_session(session_id)?;
838 return Ok((session_id.clone(), false));
839 }
840 if command.continue_session {
841 if let Some(session_id) = read_current_session(&self.config)?
842 && self.store()?.load_session(&session_id).is_ok()
843 {
844 return Ok((session_id, false));
845 }
846 if let Some(session) = self.store()?.latest_session()? {
847 return Ok((session.session_id.as_str().to_string(), false));
848 }
849 }
850 let session = self
851 .store()?
852 .create_session(profile, Some("CLI session".to_string()))?;
853 Ok((session.session_id.as_str().to_string(), true))
854 }
855
856 fn session(&mut self, command: SessionCommand) -> CliResult<String> {
857 match command {
858 SessionCommand::List(command) => {
859 let sessions = self.store()?.list_sessions(command.limit)?;
860 render_sessions(&sessions, command.output)
861 }
862 SessionCommand::Search(command) => {
863 let output = command.output;
864 let page = self.store()?.search_sessions(search_query(command))?;
865 render_session_search(&page, output)
866 }
867 SessionCommand::Show(command) => {
868 let session = self.store()?.load_session(&command.session_id)?;
869 let runs = self.store()?.list_runs(&command.session_id, command.runs)?;
870 let value = session_value(&session);
871 render_session_show(&value, &runs, command.output)
872 }
873 SessionCommand::Replay(command) => {
874 let messages = self.store()?.replay_display(
875 &command.session_id,
876 command.run.as_deref(),
877 command.after,
878 )?;
879 match command.output {
880 OutputMode::Text => Ok(render_display_text(&messages)),
881 OutputMode::DisplayJsonl => render_display_jsonl(&messages),
882 OutputMode::AguiJsonl => render_agui_jsonl(&messages),
883 OutputMode::Json => Ok(format!(
884 "{}\n",
885 serde_json::to_string(&json!({
886 "sessionId": command.session_id,
887 "runId": command.run,
888 "messages": messages,
889 "status": "replayed"
890 }))?
891 )),
892 OutputMode::Silent => Ok(format!(
893 "session_id={}\nmessages={}\nstatus=replayed\n",
894 command.session_id,
895 messages.len()
896 )),
897 }
898 }
899 SessionCommand::Delete(command) => {
900 if !command.yes {
901 return Err(CliError::Usage(
902 "pass --yes to delete a local session".to_string(),
903 ));
904 }
905 let session_id = self.store()?.resolve_session_prefix(&command.session_id)?;
906 let deleted = self.store()?.delete_session(&session_id)?;
907 if read_current_session(&self.config)?.as_deref() == Some(session_id.as_str()) {
908 let _removed =
909 remove_file_if_exists(&self.config.project_dir.join("state.json"))?;
910 }
911 render_session_delete(&session_id, deleted, command.output)
912 }
913 SessionCommand::Trim(command) => {
914 let sessions = if command.all {
915 self.store()?.all_session_ids()?
916 } else if let Some(session_id) = command.session {
917 vec![session_id]
918 } else {
919 read_current_session(&self.config)?.into_iter().collect()
920 };
921 let older_than = command
922 .older_than
923 .as_deref()
924 .map(parse_duration)
925 .transpose()?;
926 let report = self.store()?.trim_with_age(
927 sessions,
928 command.keep_runs,
929 older_than,
930 command.dry_run,
931 )?;
932 render_trim_report(&report, command.output)
933 }
934 }
935 }
936
937 fn approval(&mut self, command: ApprovalCommand) -> CliResult<String> {
938 match command {
939 ApprovalCommand::List(command) => self.approval_list(&command),
940 ApprovalCommand::Show { approval_id } => {
941 let approval = self.store()?.load_approval(&approval_id)?;
942 Ok(format!("{}\n", serde_json::to_string(&approval)?))
943 }
944 ApprovalCommand::Approve(command) => {
945 self.approval_decision(&command, ApprovalStatus::Approved)
946 }
947 ApprovalCommand::Reject(command) => {
948 self.approval_decision(&command, ApprovalStatus::Denied)
949 }
950 }
951 }
952
953 fn approval_list(&mut self, command: &ApprovalListCommand) -> CliResult<String> {
954 let approvals = self
955 .store()?
956 .list_approvals(command.session.as_deref(), command.run.as_deref())?;
957 render_approvals(&approvals, command.output)
958 }
959
960 fn approval_decision(
961 &mut self,
962 command: &ApprovalDecisionCommand,
963 status: ApprovalStatus,
964 ) -> CliResult<String> {
965 let approval =
966 self.store()?
967 .decide_approval(&command.approval_id, status, command.reason.clone())?;
968 match command.output {
969 OutputMode::Text => Ok(format!(
970 "approval_id={}\nstatus={}\nrun_id={}\n",
971 approval.approval_id,
972 approval_status_name(approval.status),
973 approval.run_id.as_str()
974 )),
975 OutputMode::DisplayJsonl | OutputMode::AguiJsonl | OutputMode::Json => {
976 Ok(format!("{}\n", serde_json::to_string(&approval)?))
977 }
978 OutputMode::Silent => Ok(format!(
979 "approval_id={}\nstatus={}\n",
980 approval.approval_id,
981 approval_status_name(approval.status)
982 )),
983 }
984 }
985
986 fn deferred(&mut self, command: DeferredCommand) -> CliResult<String> {
987 match command {
988 DeferredCommand::List(command) => self.deferred_list(&command),
989 DeferredCommand::Show { deferred_id } => {
990 let deferred = self.store()?.load_deferred_tool(&deferred_id)?;
991 Ok(format!("{}\n", serde_json::to_string(&deferred)?))
992 }
993 DeferredCommand::Complete(command) => self.deferred_complete(&command),
994 DeferredCommand::Fail(command) => self.deferred_fail(&command),
995 }
996 }
997
998 fn deferred_list(&mut self, command: &DeferredListCommand) -> CliResult<String> {
999 let records = self
1000 .store()?
1001 .list_deferred_tools(command.session.as_deref(), command.run.as_deref())?;
1002 render_deferred(&records, command.output)
1003 }
1004
1005 fn deferred_complete(&mut self, command: &DeferredCompleteCommand) -> CliResult<String> {
1006 let value = serde_json::from_str::<Value>(&command.result)
1007 .map_err(|error| CliError::Usage(format!("invalid deferred result JSON: {error}")))?;
1008 let record = self
1009 .store()?
1010 .complete_deferred_tool(&command.deferred_id, value)?;
1011 render_deferred_decision(&record, command.output)
1012 }
1013
1014 fn deferred_fail(&mut self, command: &DeferredFailCommand) -> CliResult<String> {
1015 let record = self
1016 .store()?
1017 .fail_deferred_tool(&command.deferred_id, &command.error)?;
1018 render_deferred_decision(&record, command.output)
1019 }
1020
1021 fn resume(&mut self, command: &ResumeCommand) -> CliResult<String> {
1022 let session_id = self.resolve_session_id(command.session.as_deref())?;
1023 let source_run = self.resolve_resume_run(&session_id, command.run.as_deref())?;
1024 let hitl_resume = source_run.status == RunStatus::Waiting;
1025 let run_command = RunCommand {
1026 prompt: Some(format!(
1027 "{}\n\nResuming from run {} with any persisted approval and deferred-tool decisions.",
1028 command.prompt,
1029 source_run.run_id.as_str()
1030 )),
1031 prompt_parts: Vec::new(),
1032 session: Some(session_id),
1033 continue_session: false,
1034 new_session: false,
1035 run: Some(source_run.run_id.as_str().to_string()),
1036 branch_from: None,
1037 profile: source_run.profile.clone(),
1038 output: command.output,
1039 hitl: command.hitl,
1040 goal: None,
1041 worker: None,
1042 worker_label: None,
1043 worktree: None,
1044 worktree_name: None,
1045 branch: None,
1046 session_affinity_id: None,
1047 environment_attachments: Vec::new(),
1048 hitl_resume,
1049 };
1050 self.run_prompt(&run_command)
1051 }
1052
1053 fn resolve_session_id(&mut self, requested: Option<&str>) -> CliResult<String> {
1054 if let Some(session_id) = requested {
1055 self.store()?.load_session(session_id)?;
1056 return Ok(session_id.to_string());
1057 }
1058 if let Some(session_id) = read_current_session(&self.config)?
1059 && self.store()?.load_session(&session_id).is_ok()
1060 {
1061 return Ok(session_id);
1062 }
1063 self.store()?
1064 .latest_session()?
1065 .map(|session| session.session_id.as_str().to_string())
1066 .ok_or_else(|| CliError::NotFound("session".to_string()))
1067 }
1068
1069 fn resolve_resume_run(
1070 &mut self,
1071 session_id: &str,
1072 requested: Option<&str>,
1073 ) -> CliResult<starweaver_session::RunRecord> {
1074 if let Some(run_id) = requested {
1075 return self.store()?.load_run(session_id, run_id);
1076 }
1077 let session = self.store()?.load_session(session_id)?;
1078 let run_id = session
1079 .active_run_id
1080 .as_ref()
1081 .or(session.head_run_id.as_ref())
1082 .ok_or_else(|| CliError::NotFound("run".to_string()))?;
1083 self.store()?.load_run(session_id, run_id.as_str())
1084 }
1085}
1086
1087fn run_hitl_resume_claim_operation(
1088 config: CliConfig,
1089 operation: HitlResumeClaimOperation,
1090) -> CliResult<()> {
1091 thread::spawn(move || {
1092 let store =
1093 LocalSessionStore::new(config).map_err(|error| CliError::Storage(error.to_string()))?;
1094 let runtime = tokio::runtime::Builder::new_current_thread()
1095 .enable_all()
1096 .build()
1097 .map_err(|error| CliError::Run(error.to_string()))?;
1098 runtime
1099 .block_on(async {
1100 match operation {
1101 HitlResumeClaimOperation::Claim(claim) => store.claim_hitl_resume(claim).await,
1102 HitlResumeClaimOperation::Start {
1103 session_id,
1104 run_id,
1105 claim_id,
1106 } => {
1107 store
1108 .mark_hitl_resume_started(&session_id, &run_id, &claim_id)
1109 .await
1110 }
1111 HitlResumeClaimOperation::Release {
1112 session_id,
1113 run_id,
1114 claim_id,
1115 } => {
1116 store
1117 .release_hitl_resume_claim(&session_id, &run_id, &claim_id)
1118 .await
1119 }
1120 }
1121 })
1122 .map_err(|error| CliError::Storage(error.to_string()))
1123 })
1124 .join()
1125 .map_err(|_| CliError::Run("HITL resume claim worker panicked".to_string()))?
1126}
1127
1128#[allow(dead_code)]
1129fn start_oauth_refresh_guard(config: &CliConfig) -> CliResult<Option<OAuthRefreshGuard>> {
1130 if !config.oauth_refresh.enabled {
1131 return Ok(None);
1132 }
1133 let models = list_profiles(config)
1134 .into_iter()
1135 .map(|profile| profile.model_id)
1136 .collect::<Vec<_>>();
1137 if config.oauth_refresh.interval_seconds == 0 {
1138 return Err(CliError::Usage(
1139 "invalid oauth_refresh.interval_seconds: value must be positive".to_string(),
1140 ));
1141 }
1142 if config.oauth_refresh.failure_retry_seconds == 0 {
1143 return Err(CliError::Usage(
1144 "invalid oauth_refresh.failure_retry_seconds: value must be positive".to_string(),
1145 ));
1146 }
1147 let mut supervisor = create_oauth_refresh_supervisor_for_models_with_options(
1148 models.iter().map(String::as_str),
1149 Duration::from_secs(config.oauth_refresh.interval_seconds),
1150 Duration::from_secs(config.oauth_refresh.failure_retry_seconds),
1151 config.oauth_refresh.refresh_on_startup,
1152 )
1153 .map_err(oauth_cli_error)?;
1154 let Some(mut supervisor) = supervisor.take() else {
1155 return Ok(None);
1156 };
1157 let (stop_sender, stop_receiver) = mpsc::channel::<()>();
1158 let handle = thread::Builder::new()
1159 .name("starweaver-oauth-refresh".to_string())
1160 .spawn(move || {
1161 let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
1162 .enable_all()
1163 .build()
1164 else {
1165 return;
1166 };
1167 runtime.block_on(async move {
1168 supervisor.start().await;
1169 let _ = tokio::task::spawn_blocking(move || stop_receiver.recv()).await;
1170 supervisor.shutdown().await;
1171 });
1172 })
1173 .map_err(|error| CliError::Run(error.to_string()))?;
1174 Ok(Some(OAuthRefreshGuard {
1175 stop_sender,
1176 handle: Some(handle),
1177 }))
1178}
1179
1180fn render_json_lines<T: serde::Serialize>(items: &[T]) -> CliResult<String> {
1181 items
1182 .iter()
1183 .map(|item| serde_json::to_string(item).map(|line| format!("{line}\n")))
1184 .collect::<Result<String, _>>()
1185 .map_err(CliError::from)
1186}
1187
1188const fn run_status_name(status: starweaver_session::RunStatus) -> &'static str {
1189 status.as_str()
1190}
1191
1192fn parse_duration(value: &str) -> CliResult<chrono::Duration> {
1193 let trimmed = value.trim();
1194 if trimmed.is_empty() {
1195 return Err(CliError::Usage("duration cannot be empty".to_string()));
1196 }
1197 let (number, unit) = trimmed.split_at(
1198 trimmed
1199 .find(|ch: char| !ch.is_ascii_digit())
1200 .unwrap_or(trimmed.len()),
1201 );
1202 let amount = number
1203 .parse::<i64>()
1204 .map_err(|error| CliError::Usage(error.to_string()))?;
1205 let duration = match unit {
1206 "" | "s" | "sec" | "secs" => chrono::Duration::seconds(amount),
1207 "m" | "min" | "mins" => chrono::Duration::minutes(amount),
1208 "h" | "hr" | "hrs" => chrono::Duration::hours(amount),
1209 "d" | "day" | "days" => chrono::Duration::days(amount),
1210 other => return Err(CliError::Usage(format!("unknown duration unit: {other}"))),
1211 };
1212 Ok(duration)
1213}
1214
1215#[cfg(test)]
1216mod tests;