1use std::collections::BTreeMap;
26use std::io::{self, Read};
27use std::path::{Path, PathBuf};
28use std::pin::Pin;
29use std::process::ExitCode;
30use std::sync::Arc;
31
32use futures::future::BoxFuture;
33use futures::stream::BoxStream;
34use pi_ai::{AssistantMessageEvent, Context, Model, Provider, ProviderError, StreamOptions};
35
36use crate::cli::bootstrap::{
37 BootstrapInputs, BootstrapIo, BootstrapOutcome, RuntimeFactory, RuntimeFactoryOptions,
38 RuntimeHandle, run_bootstrap,
39};
40use crate::cli::package_manager_cli::{ListedPackage, ListedScope, PackageHandler, PackageOutput};
41use crate::core::agent_session_runtime::{
42 AgentSessionRuntime, AgentSessionRuntimeServices, CreateAgentSessionRuntimeFactory,
43 CreateAgentSessionRuntimeOptions, CreateAgentSessionRuntimeResult,
44};
45use crate::core::agent_session_services::{
46 AgentSessionRuntimeDiagnostic, AgentSessionServices, AgentSessionServicesError,
47 CreateAgentSessionResult, CreateAgentSessionServicesOptions, ExtensionFlagValue,
48 create_agent_session_from_services, create_agent_session_services_with_trust,
49};
50use crate::core::config::get_agent_dir;
51use crate::core::model_resolver::{
52 ResolveCliModelOptions, ResolveCliModelResult, ResolveModelScopeResult, resolve_cli_model,
53 resolve_model_scope_with_diagnostics,
54};
55use crate::core::model_runtime::ModelRuntime;
56use crate::core::package_manager::{PackageManager, PackageManagerOptions, Scope};
57use crate::core::resources::ResourceLoader;
58use crate::core::settings::{SettingsManager, SettingsManagerCreateOptions};
59use crate::core::system_prompt::{BuildSystemPromptOptions, build_system_prompt};
60use crate::core::trust::{
61 ProjectTrustStore, ResolveProjectTrustedOptions, resolve_project_trusted,
62};
63use crate::modes::interactive::runtime::{InteractiveRuntimeOptions, run_interactive_mode};
64use crate::modes::rpc::server::run_rpc_mode;
65use crate::modes::run::{DefaultDispatcher, run_mode_default};
66
67pub struct Io {
69 pub bootstrap_io: Arc<dyn BootstrapIo>,
71 pub factory: Arc<dyn RuntimeFactory>,
73 pub package_handler: Arc<dyn PackageHandler>,
75 pub package_output: Arc<dyn PackageOutput>,
77 pub dispatcher: Arc<DefaultDispatcher>,
79}
80
81impl Io {
82 #[must_use]
94 pub fn real() -> Self {
95 let dispatcher = DefaultDispatcher::new()
96 .with_interactive(|dispatched, runtime| {
97 Box::pin(async move {
98 let hardware_cursor = {
99 let session = runtime.session();
100 session.lock_settings().get_show_hardware_cursor()
101 };
102 let options = InteractiveRuntimeOptions {
103 initial_message: dispatched.initial_message,
104 initial_images: dispatched.initial_images,
105 remaining_messages: dispatched.remaining_messages,
106 migrations: dispatched.migrations,
107 hardware_cursor,
108 ..InteractiveRuntimeOptions::default()
109 };
110 run_interactive_mode(runtime, options)
111 .await
112 .map_err(|e| format!("interactive: {e}"))
113 })
114 })
115 .with_rpc(|_dispatched, runtime| {
116 Box::pin(async move {
117 let code = run_rpc_mode(runtime).await;
118 Ok(u8::try_from(code).unwrap_or(1))
119 })
120 });
121 Self {
122 bootstrap_io: Arc::new(RealBootstrapIo),
123 factory: Arc::new(RealRuntimeFactory),
124 package_handler: Arc::new(RealPackageHandler::new(false)),
125 package_output: Arc::new(ProductOutputSink),
126 dispatcher: Arc::new(dispatcher),
127 }
128 }
129
130 #[must_use]
132 pub fn with_rpc<F>(mut self, f: F) -> Self
133 where
134 F: Fn(
135 crate::cli::bootstrap::Dispatched,
136 Arc<AgentSessionRuntime>,
137 ) -> BoxFuture<'static, Result<u8, String>>
138 + Send
139 + Sync
140 + 'static,
141 {
142 let dispatcher = Arc::try_unwrap(self.dispatcher).unwrap_or_else(|arc| DefaultDispatcher {
143 rpc: arc.rpc.clone(),
144 interactive: arc.interactive.clone(),
145 });
146 self.dispatcher = Arc::new(dispatcher.with_rpc(f));
147 self
148 }
149
150 #[must_use]
152 pub fn with_interactive<F>(mut self, f: F) -> Self
153 where
154 F: Fn(
155 crate::cli::bootstrap::Dispatched,
156 Arc<AgentSessionRuntime>,
157 ) -> BoxFuture<'static, Result<u8, String>>
158 + Send
159 + Sync
160 + 'static,
161 {
162 let dispatcher = Arc::try_unwrap(self.dispatcher).unwrap_or_else(|arc| DefaultDispatcher {
163 rpc: arc.rpc.clone(),
164 interactive: arc.interactive.clone(),
165 });
166 self.dispatcher = Arc::new(dispatcher.with_interactive(f));
167 self
168 }
169
170 #[must_use]
172 pub fn custom(
173 bootstrap_io: Arc<dyn BootstrapIo>,
174 factory: Arc<dyn RuntimeFactory>,
175 package_handler: Arc<dyn PackageHandler>,
176 package_output: Arc<dyn PackageOutput>,
177 dispatcher: Arc<DefaultDispatcher>,
178 ) -> Self {
179 Self {
180 bootstrap_io,
181 factory,
182 package_handler,
183 package_output,
184 dispatcher,
185 }
186 }
187}
188
189#[must_use]
192pub fn run(args: Vec<String>, io: Io) -> ExitCode {
193 let runtime = match tokio::runtime::Builder::new_multi_thread()
194 .enable_all()
195 .build()
196 {
197 Ok(rt) => rt,
198 Err(err) => {
199 crate::core::output_guard::ProductOutput::writeln(&format!(
200 "Error: failed to start runtime: {err}"
201 ));
202 return ExitCode::from(1);
203 }
204 };
205 let result = runtime.block_on(async move { run_pipeline(args, &io).await });
206 drop(runtime);
207 result
208}
209
210pub async fn run_pipeline(args: Vec<String>, io: &Io) -> ExitCode {
213 let parsed = crate::cli::args::parse_args(&args);
214 let offline = parsed.offline
215 || io
216 .bootstrap_io
217 .env("PI_OFFLINE")
218 .as_deref()
219 .is_some_and(|value| {
220 matches!(
221 value.trim().to_ascii_lowercase().as_str(),
222 "1" | "true" | "yes" | "on"
223 )
224 });
225 io.package_handler.set_offline(offline);
226
227 let outcome = run_bootstrap(BootstrapInputs {
228 args,
229 io: io.bootstrap_io.as_ref(),
230 factory: io.factory.as_ref(),
231 package_handler: io.package_handler.as_ref(),
232 package_output: io.package_output.as_ref(),
233 })
234 .await;
235
236 match outcome {
237 BootstrapOutcome::Exit { code, drain_quirk } => {
238 let _ = drain_quirk;
239 ExitCode::from(code)
240 }
241 BootstrapOutcome::Dispatch(dispatched) => {
242 run_mode_default(dispatched, io.dispatcher.as_ref()).await
243 }
244 }
245}
246
247struct RealBootstrapIo;
252
253impl BootstrapIo for RealBootstrapIo {
254 fn env(&self, key: &str) -> Option<String> {
255 std::env::var(key).ok()
256 }
257 fn set_env(&self, _key: &str, _value: &str) {
258 }
262 fn cwd(&self) -> PathBuf {
263 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
264 }
265 fn stdin_is_tty(&self) -> bool {
266 io::IsTerminal::is_terminal(&io::stdin())
267 }
268 fn stdout_is_tty(&self) -> bool {
269 io::IsTerminal::is_terminal(&io::stdout())
270 }
271 fn read_piped_stdin<'a>(
272 &'a self,
273 ) -> Pin<Box<dyn Future<Output = io::Result<Option<String>>> + Send + 'a>> {
274 Box::pin(async move {
275 if self.stdin_is_tty() {
276 return Ok(None);
277 }
278 let mut buf = String::new();
279 let stdin = io::stdin();
280 let mut handle = stdin.lock();
281 handle.read_to_string(&mut buf)?;
282 let trimmed = buf.trim();
283 if trimmed.is_empty() {
284 Ok(None)
285 } else {
286 Ok(Some(trimmed.to_owned()))
287 }
288 })
289 }
290 fn write_stdout(&self, line: &str) {
291 crate::core::output_guard::ProductOutput::writeln(line);
292 }
293 fn write_stderr(&self, line: &str) {
294 use std::io::Write;
295 let mut stderr = std::io::stderr().lock();
296 let _ = stderr.write_all(line.as_bytes());
297 let _ = stderr.write_all(b"\n");
298 }
299}
300
301#[derive(Clone)]
306struct RuntimeProvider(ModelRuntime);
307
308impl Provider for RuntimeProvider {
309 fn stream(
310 &self,
311 model: &Model,
312 context: Context,
313 options: StreamOptions,
314 ) -> BoxStream<'static, Result<AssistantMessageEvent, ProviderError>> {
315 self.0.stream_simple(model.clone(), context, options)
316 }
317}
318fn build_builtin_tools(
319 cwd: &Path,
320 settings: &SettingsManager,
321 model: Option<Model>,
322) -> Vec<Arc<dyn pi_agent::AgentTool>> {
323 use crate::core::tools::{
324 bash::create_bash_tool,
325 edit::EditTool,
326 find::FindTool,
327 grep::GrepTool,
328 ls::LsTool,
329 read::{ReadTool, ReadToolOptions},
330 write::WriteTool,
331 };
332
333 vec![
334 Arc::new(ReadTool::with_options(ReadToolOptions {
335 cwd: cwd.to_path_buf(),
336 auto_resize_images: settings.get_image_auto_resize(),
337 model,
338 })),
339 create_bash_tool(cwd.to_path_buf()),
340 Arc::new(EditTool::new(cwd)),
341 Arc::new(WriteTool::new(cwd)),
342 Arc::new(GrepTool::new(cwd)),
343 Arc::new(FindTool::new(cwd)),
344 Arc::new(LsTool::new(cwd)),
345 ]
346}
347
348fn thinking_level_from_str(level: &str) -> Option<pi_ai::ModelThinkingLevel> {
349 match level {
350 "off" => Some(pi_ai::ModelThinkingLevel::Off),
351 "minimal" => Some(pi_ai::ModelThinkingLevel::Minimal),
352 "low" => Some(pi_ai::ModelThinkingLevel::Low),
353 "medium" => Some(pi_ai::ModelThinkingLevel::Medium),
354 "high" => Some(pi_ai::ModelThinkingLevel::High),
355 "xhigh" => Some(pi_ai::ModelThinkingLevel::Xhigh),
356 _ => None,
357 }
358}
359
360struct SessionBuildOptions {
361 cwd: String,
362 session_manager: crate::core::sessions::SessionManager,
363 settings_manager: SettingsManager,
364 session_result: CreateAgentSessionResult,
365 tools: Vec<Arc<dyn pi_agent::AgentTool>>,
366 messages: Vec<pi_agent::AgentMessage>,
367 system_prompt: String,
368 skills: Vec<crate::core::resources::skills::Skill>,
369 prompt_templates: Vec<crate::core::resources::prompts::PromptTemplate>,
370}
371
372struct BuiltSession {
373 session: Arc<crate::core::agent_session::AgentSession>,
374 diagnostics: Vec<AgentSessionRuntimeDiagnostic>,
375 model_fallback_message: Option<String>,
376}
377
378fn build_session(options: SessionBuildOptions) -> Result<BuiltSession, String> {
379 let session_result = options.session_result;
380 let host_runner = session_result.extension_runner.clone();
381 let trait_runner = host_runner
382 .clone()
383 .map(|runner| runner as Arc<dyn crate::core::agent_session::ExtensionRunner>);
384 let config = crate::core::agent_session::AgentSessionConfig {
385 agent: None,
386 provider: Some(Arc::new(RuntimeProvider(
387 session_result.model_runtime.clone(),
388 ))),
389 session_manager: options.session_manager,
390 settings_manager: options.settings_manager,
391 cwd: options.cwd,
392 scoped_models: session_result
393 .scoped_models
394 .iter()
395 .map(|scoped| crate::core::agent_session::ScopedModel {
396 model: scoped.model.clone(),
397 thinking_level: scoped.thinking_level,
398 })
399 .collect(),
400 initial_active_tool_names: Some(session_result.initial_active_tool_names),
401 allowed_tool_names: session_result.allowed_tool_names,
402 excluded_tool_names: session_result.excluded_tool_names,
403 model: session_result.model,
404 thinking_level: session_result.thinking_level,
405 system_prompt: options.system_prompt,
406 tools: options.tools,
407 messages: options.messages,
408 extension_runner: trait_runner,
409 host_extension_runner: host_runner,
410 model_runtime: Some(Arc::new(session_result.model_runtime)),
411 compaction_stream_override: None,
412 skills: options.skills,
413 prompt_templates: options.prompt_templates,
414 resource_loader: Some(session_result.resource_loader),
415 session_start_event: session_result.session_start_event,
416 base_config: None,
417 };
418 let session =
419 crate::core::agent_session::AgentSession::new(config).map_err(|error| error.to_string())?;
420 Ok(BuiltSession {
421 session,
422 diagnostics: session_result.diagnostics,
423 model_fallback_message: session_result.model_fallback_message,
424 })
425}
426
427struct SessionResources {
428 skills: Vec<crate::core::resources::skills::Skill>,
429 prompt_templates: Vec<crate::core::resources::prompts::PromptTemplate>,
430 context_files: Vec<crate::core::resources::AgentsFile>,
431 custom_prompt: Option<String>,
432 append_prompt: Option<String>,
433}
434
435fn session_resources(loader: &crate::core::resources::DefaultResourceLoader) -> SessionResources {
436 SessionResources {
437 skills: loader.get_skills().0.to_vec(),
438 prompt_templates: loader.get_prompts().0.to_vec(),
439 context_files: loader.get_agents_files().to_vec(),
440 custom_prompt: loader.get_system_prompt().map(str::to_owned),
441 append_prompt: (!loader.get_append_system_prompt().is_empty())
442 .then(|| loader.get_append_system_prompt().join("\n\n")),
443 }
444}
445
446struct RestoredSession {
447 has_existing_session: bool,
448 saved_session_model: Option<(String, String)>,
449 saved_thinking_level: Option<pi_ai::ModelThinkingLevel>,
450 messages: Vec<pi_agent::AgentMessage>,
451}
452
453fn restore_session(context: crate::core::sessions::SessionContext) -> RestoredSession {
454 RestoredSession {
455 has_existing_session: !context.messages.is_empty(),
456 saved_session_model: context.model.map(|model| (model.provider, model.model_id)),
457 saved_thinking_level: thinking_level_from_str(&context.thinking_level),
458 messages: context.messages,
459 }
460}
461
462fn extension_flag_values(args: &crate::cli::args::Args) -> BTreeMap<String, ExtensionFlagValue> {
463 args.unknown_flags
464 .iter()
465 .map(|(key, value)| {
466 let value = match value {
467 crate::cli::args::FlagValue::Bool => ExtensionFlagValue::Bool(true),
468 crate::cli::args::FlagValue::Str(value) => ExtensionFlagValue::Str(value.clone()),
469 };
470 (key.clone(), value)
471 })
472 .collect()
473}
474
475fn no_tools_mode(
476 args: &crate::cli::args::Args,
477) -> Option<crate::core::agent_session_services::NoToolsMode> {
478 if args.no_tools {
479 Some(crate::core::agent_session_services::NoToolsMode::All)
480 } else if args.no_builtin_tools {
481 Some(crate::core::agent_session_services::NoToolsMode::Builtin)
482 } else {
483 None
484 }
485}
486
487async fn create_runtime_services(
488 cwd: &str,
489 agent_dir: &str,
490 args: &crate::cli::args::Args,
491) -> Result<AgentSessionServices, String> {
492 create_agent_session_services_with_trust(
493 CreateAgentSessionServicesOptions {
494 cwd: PathBuf::from(cwd),
495 agent_dir: Some(PathBuf::from(agent_dir)),
496 extension_flag_values: Some(extension_flag_values(args)),
497 resource_loader_options: Some(
498 crate::core::agent_session_services::ResourceLoaderServiceOptions {
499 no_extensions: args.no_extensions,
500 no_skills: args.no_skills,
501 no_prompt_templates: args.no_prompt_templates,
502 no_themes: args.no_themes,
503 no_context_files: args.no_context_files,
504 system_prompt: args.system_prompt.clone(),
505 append_system_prompt: (!args.append_system_prompt.is_empty())
506 .then(|| args.append_system_prompt.clone()),
507 additional_extension_paths: args.extensions.clone(),
508 ..Default::default()
509 },
510 ),
511 ..Default::default()
512 },
513 args.project_trust_override,
514 )
515 .await
516 .map_err(|error| error.to_string())
517}
518
519struct ResolvedModels {
520 cli: ResolveCliModelResult,
521 scope: ResolveModelScopeResult,
522 diagnostics: Vec<AgentSessionRuntimeDiagnostic>,
523}
524
525async fn resolve_models(args: &crate::cli::args::Args, runtime: &ModelRuntime) -> ResolvedModels {
526 let cli = resolve_cli_model(ResolveCliModelOptions {
527 cli_provider: args.provider.as_deref(),
528 cli_model: args.model.as_deref(),
529 cli_thinking: args.thinking,
530 model_runtime: runtime,
531 });
532 let mut diagnostics = Vec::new();
533 if let Some(error) = &cli.error {
534 diagnostics.push(AgentSessionRuntimeDiagnostic::error(error.clone()));
535 }
536 if let Some(warning) = &cli.warning {
537 diagnostics.push(AgentSessionRuntimeDiagnostic::warning(warning.clone()));
538 }
539 let scope = resolve_model_scope_with_diagnostics(&args.models, runtime).await;
540 diagnostics.extend(
541 scope
542 .diagnostics
543 .iter()
544 .map(|diagnostic| AgentSessionRuntimeDiagnostic::warning(diagnostic.message.clone())),
545 );
546 ResolvedModels {
547 cli,
548 scope,
549 diagnostics,
550 }
551}
552async fn apply_cli_api_key(
553 api_key: Option<&str>,
554 selected_model: Option<&Model>,
555 runtime: &ModelRuntime,
556 diagnostics: &mut Vec<AgentSessionRuntimeDiagnostic>,
557) -> Result<(), String> {
558 let Some(api_key) = api_key else {
559 return Ok(());
560 };
561 let Some(model) = selected_model else {
562 diagnostics.push(AgentSessionRuntimeDiagnostic::error(
563 "--api-key requires a model to be specified via --model, --provider/--model, or --models",
564 ));
565 return Ok(());
566 };
567
568 runtime
569 .set_runtime_api_key(&model.provider, api_key)
570 .await
571 .map_err(|error| error.to_string())?;
572 runtime
573 .get_available(None)
574 .await
575 .map_err(|error| error.to_string())?;
576 Ok(())
577}
578
579fn build_session_inputs(
583 cwd: &str,
584 agent_dir: &str,
585 project_trusted: bool,
586 model: Option<Model>,
587 initial_active_tool_names: Vec<String>,
588 resources: &SessionResources,
589) -> (SettingsManager, Vec<Arc<dyn pi_agent::AgentTool>>, String) {
590 let settings_manager = SettingsManager::create(
591 cwd,
592 Some(agent_dir),
593 SettingsManagerCreateOptions::default().project_trusted(project_trusted),
594 );
595 let tools = build_builtin_tools(Path::new(cwd), &settings_manager, model);
596 let system_prompt = build_system_prompt(&BuildSystemPromptOptions {
597 custom_prompt: resources.custom_prompt.clone(),
598 selected_tools: Some(initial_active_tool_names),
599 tool_snippets: None,
600 prompt_guidelines: None,
601 append: resources.append_prompt.clone(),
602 cwd: cwd.to_owned(),
603 context_files: Some(resources.context_files.clone()),
604 skills: Some(resources.skills.clone()),
605 });
606 (settings_manager, tools, system_prompt)
607}
608struct RealRuntimeFactory;
610
611impl RuntimeFactory for RealRuntimeFactory {
612 fn create(
613 &self,
614 options: RuntimeFactoryOptions,
615 ) -> BoxFuture<'_, Result<RuntimeHandle, String>> {
616 Box::pin(async move {
617 let cwd = options.cwd.clone();
618 let agent_dir = options.agent_dir.clone();
619 let parsed = options.parsed;
620 let session_context = options
621 .session_manager
622 .build_session_context()
623 .map_err(|error| error.to_string())?;
624 let RestoredSession {
625 has_existing_session,
626 saved_session_model,
627 saved_thinking_level,
628 messages: existing_messages,
629 } = restore_session(session_context);
630
631 let services = create_runtime_services(&cwd, &agent_dir, &parsed).await?;
632 let project_trusted = services.settings_manager().is_project_trusted();
633
634 let ResolvedModels {
636 cli: cli_resolved,
637 scope,
638 diagnostics: mut pre_session_diagnostics,
639 } = resolve_models(&parsed, &services.model_runtime).await;
640
641 let resources = session_resources(&services.resource_loader);
642 let no_tools = no_tools_mode(&parsed);
643
644 let thinking_level = parsed
645 .thinking
646 .or(cli_resolved.thinking_level)
647 .or(saved_thinking_level);
648
649 let mut session_result = create_agent_session_from_services(
651 crate::core::agent_session_services::CreateAgentSessionFromServicesOptions {
652 services,
653 model: cli_resolved.model.clone(),
654 thinking_level,
655 scoped_models: scope.scoped_models,
656 tools: if parsed.tools.is_empty() {
657 None
658 } else {
659 Some(parsed.tools.clone())
660 },
661 exclude_tools: if parsed.exclude_tools.is_empty() {
662 None
663 } else {
664 Some(parsed.exclude_tools.clone())
665 },
666 no_tools,
667 session_start_event: None,
668 saved_session_model,
669 has_existing_session,
670 },
671 )
672 .await
673 .map_err(|e: AgentSessionServicesError| format!("{e}"))?;
674 apply_cli_api_key(
675 parsed.api_key.as_deref(),
676 session_result.model.as_ref(),
677 &session_result.model_runtime,
678 &mut pre_session_diagnostics,
679 )
680 .await?;
681 session_result
682 .diagnostics
683 .splice(0..0, pre_session_diagnostics);
684
685 let (settings_manager, tools, system_prompt) = build_session_inputs(
686 &cwd,
687 &agent_dir,
688 project_trusted,
689 session_result.model.clone(),
690 session_result.initial_active_tool_names.clone(),
691 &resources,
692 );
693
694 let built = build_session(SessionBuildOptions {
695 cwd: cwd.clone(),
696 session_manager: options.session_manager,
697 settings_manager,
698 session_result,
699 tools,
700 messages: existing_messages,
701 system_prompt,
702 skills: resources.skills,
703 prompt_templates: resources.prompt_templates,
704 })?;
705 let runtime = AgentSessionRuntime::new(
706 built.session,
707 AgentSessionRuntimeServices {
708 cwd: PathBuf::from(&cwd),
709 agent_dir: PathBuf::from(&agent_dir),
710 },
711 Arc::new(RealReplacementFactory {
712 project_trust_override: parsed.project_trust_override,
713 }),
714 built.diagnostics,
715 built.model_fallback_message,
716 );
717
718 Ok(RuntimeHandle {
719 runtime: Arc::new(runtime),
720 })
721 })
722 }
723
724 fn supports_interactive(&self) -> bool {
725 true
726 }
727}
728
729#[derive(Clone)]
731struct RealReplacementFactory {
732 project_trust_override: Option<bool>,
733}
734
735impl CreateAgentSessionRuntimeFactory for RealReplacementFactory {
736 fn create(
737 &self,
738 options: CreateAgentSessionRuntimeOptions,
739 ) -> BoxFuture<
740 '_,
741 Result<
742 CreateAgentSessionRuntimeResult,
743 crate::core::agent_session_runtime::AgentSessionRuntimeError,
744 >,
745 > {
746 let cwd = options.cwd.clone();
747 let agent_dir = options.agent_dir.clone();
748 let session_context = options
749 .session_manager
750 .build_session_context()
751 .map_err(|error| {
752 crate::core::agent_session_runtime::AgentSessionRuntimeError::Factory(
753 error.to_string(),
754 )
755 });
756 Box::pin(async move {
757 let session_context = session_context?;
758 let RestoredSession {
759 has_existing_session,
760 saved_session_model,
761 saved_thinking_level: thinking_level,
762 messages: existing_messages,
763 } = restore_session(session_context);
764 let services = create_agent_session_services_with_trust(
765 CreateAgentSessionServicesOptions {
766 cwd: PathBuf::from(&cwd),
767 agent_dir: Some(PathBuf::from(&agent_dir)),
768 ..Default::default()
769 },
770 self.project_trust_override,
771 )
772 .await
773 .map_err(|e| {
774 crate::core::agent_session_runtime::AgentSessionRuntimeError::Factory(format!(
775 "{e}"
776 ))
777 })?;
778 let project_trusted = services.settings_manager().is_project_trusted();
779 let resources = session_resources(&services.resource_loader);
780
781 let session_result = create_agent_session_from_services(
782 crate::core::agent_session_services::CreateAgentSessionFromServicesOptions {
783 services,
784 model: None,
785 thinking_level,
786 scoped_models: Vec::new(),
787 tools: None,
788 exclude_tools: None,
789 no_tools: None,
790 session_start_event: Some(crate::core::agent_session::SessionStartEvent {
791 reason: options.start_reason,
792 previous_session_file: options.previous_session_file.clone(),
793 }),
794 saved_session_model,
795 has_existing_session,
796 },
797 )
798 .await
799 .map_err(|e| {
800 crate::core::agent_session_runtime::AgentSessionRuntimeError::Factory(format!(
801 "{e}"
802 ))
803 })?;
804
805 let built = assemble_replacement_session(
806 &cwd,
807 &agent_dir,
808 project_trusted,
809 options.session_manager,
810 session_result,
811 resources,
812 existing_messages,
813 )
814 .map_err(crate::core::agent_session_runtime::AgentSessionRuntimeError::Factory)?;
815
816 Ok(CreateAgentSessionRuntimeResult {
817 session: built.session,
818 services: AgentSessionRuntimeServices {
819 cwd: PathBuf::from(&cwd),
820 agent_dir: PathBuf::from(&agent_dir),
821 },
822 diagnostics: built.diagnostics,
823 model_fallback_message: built.model_fallback_message,
824 })
825 })
826 }
827}
828
829fn assemble_replacement_session(
833 cwd: &str,
834 agent_dir: &str,
835 project_trusted: bool,
836 session_manager: crate::core::sessions::SessionManager,
837 session_result: CreateAgentSessionResult,
838 resources: SessionResources,
839 existing_messages: Vec<pi_agent::AgentMessage>,
840) -> Result<BuiltSession, String> {
841 let SessionResources {
842 skills,
843 prompt_templates,
844 context_files,
845 custom_prompt,
846 append_prompt,
847 } = resources;
848 let settings_manager = SettingsManager::create(
849 cwd,
850 Some(agent_dir),
851 SettingsManagerCreateOptions::default().project_trusted(project_trusted),
852 );
853 let tools = build_builtin_tools(
854 Path::new(cwd),
855 &settings_manager,
856 session_result.model.clone(),
857 );
858 let system_prompt = build_system_prompt(&BuildSystemPromptOptions {
859 custom_prompt,
860 selected_tools: Some(session_result.initial_active_tool_names.clone()),
861 tool_snippets: None,
862 prompt_guidelines: None,
863 append: append_prompt,
864 cwd: cwd.to_owned(),
865 context_files: Some(context_files),
866 skills: Some(skills.clone()),
867 });
868 build_session(SessionBuildOptions {
869 cwd: cwd.to_owned(),
870 session_manager,
871 settings_manager,
872 session_result,
873 tools,
874 messages: existing_messages,
875 system_prompt,
876 skills,
877 prompt_templates,
878 })
879}
880
881struct RealPackageHandler {
887 cwd: PathBuf,
888 agent_dir: PathBuf,
889 offline: std::sync::atomic::AtomicBool,
890 project_trust_override: std::sync::Mutex<Option<bool>>,
891}
892
893impl RealPackageHandler {
894 fn new(offline: bool) -> Self {
895 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
896 let agent_dir = get_agent_dir();
897 Self {
898 cwd,
899 agent_dir,
900 offline: std::sync::atomic::AtomicBool::new(offline),
901 project_trust_override: std::sync::Mutex::new(None),
902 }
903 }
904
905 fn build_settings(&self, trusted: bool) -> SettingsManager {
906 SettingsManager::create(
907 &self.cwd,
908 Some(&self.agent_dir),
909 SettingsManagerCreateOptions::default().project_trusted(trusted),
910 )
911 }
912
913 fn resolved_project_trusted(&self) -> bool {
914 let global_settings = self.build_settings(false);
915 let trust_override = *self
916 .project_trust_override
917 .lock()
918 .unwrap_or_else(std::sync::PoisonError::into_inner);
919 resolve_project_trusted(ResolveProjectTrustedOptions {
920 cwd: self.cwd.clone(),
921 trust_store: &ProjectTrustStore::new(&self.agent_dir),
922 trust_override,
923 default_project_trust: global_settings.get_default_project_trust(),
924 extension_hook: None,
925 ui: None,
926 on_extension_error: None,
927 })
928 .unwrap_or(false)
929 }
930
931 fn build_package_manager(&self) -> PackageManager {
932 PackageManager::with_offline(
933 PackageManager::new(PackageManagerOptions::new(&self.cwd, &self.agent_dir)),
934 self.offline.load(std::sync::atomic::Ordering::Relaxed),
935 )
936 }
937
938 fn scope(local: bool) -> Scope {
939 if local { Scope::Project } else { Scope::User }
940 }
941}
942
943impl PackageHandler for RealPackageHandler {
944 fn set_offline(&self, offline: bool) {
945 self.offline
946 .store(offline, std::sync::atomic::Ordering::Relaxed);
947 }
948
949 fn set_project_trust_override(&self, trust_override: Option<bool>) {
950 *self
951 .project_trust_override
952 .lock()
953 .unwrap_or_else(std::sync::PoisonError::into_inner) = trust_override;
954 }
955
956 fn install(&self, source: &str, local: bool) -> Result<(), String> {
957 let mut settings = self.build_settings(local && self.resolved_project_trusted());
958 let pm = self.build_package_manager();
959 pm.install_and_persist(&mut settings, source, Self::scope(local))
960 .map_err(|e| format!("{e}"))
961 }
962
963 fn remove(&self, source: &str, local: bool) -> Result<bool, String> {
964 let mut settings = self.build_settings(local && self.resolved_project_trusted());
965 let pm = self.build_package_manager();
966 pm.remove_and_persist(&mut settings, source, Self::scope(local))
967 .map_err(|e| format!("{e}"))
968 }
969
970 fn list(&self) -> Result<Vec<ListedPackage>, String> {
971 let settings = self.build_settings(self.resolved_project_trusted());
972 let pm = self.build_package_manager();
973 let configured = pm
974 .list_configured_packages(&settings)
975 .map_err(|e| format!("{e}"))?;
976 Ok(configured
977 .into_iter()
978 .map(|pkg| ListedPackage {
979 display: if pkg.filtered {
980 format!("{} (filtered)", pkg.source)
981 } else {
982 pkg.source.clone()
983 },
984 installed_path: pkg.installed_path.map(|p| p.to_string_lossy().into_owned()),
985 scope: match pkg.scope {
986 Scope::User => ListedScope::User,
987 Scope::Project => ListedScope::Project,
988 },
989 })
990 .collect())
991 }
992
993 fn is_project_trusted(&self) -> bool {
994 self.resolved_project_trusted()
995 }
996
997 fn refresh_models(&self) -> Result<(), String> {
998 if self.offline.load(std::sync::atomic::Ordering::Relaxed) {
999 return Err("Cannot refresh model catalogs while offline".to_owned());
1000 }
1001 let agent_dir = self.agent_dir.clone();
1002 std::thread::spawn(move || {
1003 tokio::runtime::Builder::new_current_thread()
1004 .enable_all()
1005 .build()
1006 .map_err(|error| format!("Failed to start model refresh runtime: {error}"))?
1007 .block_on(async move {
1008 let runtime = ModelRuntime::create(
1009 crate::core::model_runtime::CreateModelRuntimeOptions {
1010 auth_path: Some(agent_dir.join("auth.json")),
1011 models_path: Some(agent_dir.join("models.json")),
1012 models_store_path: Some(agent_dir.join("models-store.json")),
1013 allow_model_network: Some(true),
1014 ..Default::default()
1015 },
1016 )
1017 .await
1018 .map_err(|error| error.to_string())?;
1019 runtime
1020 .refresh(crate::core::model_runtime::ModelsRefreshOptions {
1021 allow_network: Some(true),
1022 })
1023 .await
1024 .map(|_| ())
1025 .map_err(|error| error.to_string())
1026 })
1027 })
1028 .join()
1029 .map_err(|_| "Model refresh worker panicked".to_owned())?
1030 }
1031
1032 fn update_extensions(&self, source: Option<&str>) -> Result<(), String> {
1033 let settings = self.build_settings(self.resolved_project_trusted());
1034 let pm = self.build_package_manager();
1035 pm.update_extensions(&settings, source)
1036 .map_err(|e| format!("{e}"))
1037 }
1038
1039 fn update_self(&self, _force: bool) -> Result<bool, String> {
1040 Err(
1041 "Self-update is not supported by this build; install the new release with your package manager"
1042 .to_owned(),
1043 )
1044 }
1045}
1046
1047struct ProductOutputSink;
1052
1053impl PackageOutput for ProductOutputSink {
1054 fn status(&self, line: &str) {
1055 crate::core::output_guard::ProductOutput::writeln(line);
1056 }
1057 fn status_dim(&self, line: &str) {
1058 crate::core::output_guard::ProductOutput::writeln(line);
1059 }
1060 fn success(&self, line: &str) {
1061 crate::core::output_guard::ProductOutput::writeln(line);
1062 }
1063 fn error(&self, line: &str) {
1064 use std::io::Write;
1065 let mut stderr = std::io::stderr().lock();
1066 let _ = stderr.write_all(line.as_bytes());
1067 let _ = stderr.write_all(b"\n");
1068 }
1069}
1070
1071#[cfg(test)]
1076mod tests {
1077 use super::*;
1078 use futures::FutureExt;
1079 use std::sync::Mutex;
1080
1081 #[derive(Default)]
1082 struct FakeIo {
1083 env: Mutex<std::collections::HashMap<String, String>>,
1084 stdout: Mutex<Vec<String>>,
1085 stderr: Mutex<Vec<String>>,
1086 }
1087
1088 impl BootstrapIo for FakeIo {
1089 fn env(&self, key: &str) -> Option<String> {
1090 self.env
1091 .lock()
1092 .unwrap_or_else(std::sync::PoisonError::into_inner)
1093 .get(key)
1094 .cloned()
1095 }
1096 fn set_env(&self, key: &str, value: &str) {
1097 self.env
1098 .lock()
1099 .unwrap_or_else(std::sync::PoisonError::into_inner)
1100 .insert(key.to_owned(), value.to_owned());
1101 }
1102 fn cwd(&self) -> PathBuf {
1103 std::env::temp_dir()
1104 }
1105 fn stdin_is_tty(&self) -> bool {
1106 true
1107 }
1108 fn stdout_is_tty(&self) -> bool {
1109 true
1110 }
1111 fn read_piped_stdin<'a>(
1112 &'a self,
1113 ) -> Pin<Box<dyn Future<Output = io::Result<Option<String>>> + Send + 'a>> {
1114 Box::pin(async move { Ok(None) })
1115 }
1116 fn write_stdout(&self, line: &str) {
1117 self.stdout
1118 .lock()
1119 .unwrap_or_else(std::sync::PoisonError::into_inner)
1120 .push(line.to_owned());
1121 }
1122 fn write_stderr(&self, line: &str) {
1123 self.stderr
1124 .lock()
1125 .unwrap_or_else(std::sync::PoisonError::into_inner)
1126 .push(line.to_owned());
1127 }
1128 }
1129
1130 #[tokio::test]
1131 async fn api_key_without_selected_model_emits_reference_diagnostic() -> Result<(), String> {
1132 let runtime = ModelRuntime::create_in_memory()
1133 .await
1134 .map_err(|error| format!("failed to create in-memory model runtime: {error}"))?;
1135 let mut diagnostics = Vec::new();
1136
1137 apply_cli_api_key(Some("sk-test"), None, &runtime, &mut diagnostics)
1138 .await
1139 .map_err(|error| {
1140 format!("missing model should be diagnostic, not factory failure: {error}")
1141 })?;
1142
1143 assert_eq!(diagnostics.len(), 1);
1144 assert_eq!(
1145 diagnostics[0].message,
1146 "--api-key requires a model to be specified via --model, --provider/--model, or --models"
1147 );
1148 Ok(())
1149 }
1150
1151 #[tokio::test]
1152 async fn api_key_configures_provider_of_explicit_and_embedded_model_selection()
1153 -> Result<(), String> {
1154 for embedded_provider in [false, true] {
1155 let runtime = ModelRuntime::create_in_memory()
1156 .await
1157 .map_err(|error| format!("failed to create in-memory model runtime: {error}"))?;
1158 let catalog = runtime.get_models(None);
1159 let Some(catalog_model) = catalog.first() else {
1160 return Err("built-in model catalog is empty".to_owned());
1161 };
1162 let model_reference = if embedded_provider {
1163 format!("{}/{}", catalog_model.provider, catalog_model.id)
1164 } else {
1165 catalog_model.id.clone()
1166 };
1167 let resolved = resolve_cli_model(ResolveCliModelOptions {
1168 cli_provider: (!embedded_provider).then_some(catalog_model.provider.as_str()),
1169 cli_model: Some(&model_reference),
1170 cli_thinking: None,
1171 model_runtime: &runtime,
1172 });
1173 let Some(selected) = resolved.model else {
1174 return Err("CLI model did not resolve".to_owned());
1175 };
1176 let unselected_provider = catalog
1177 .iter()
1178 .find(|model| model.provider != selected.provider)
1179 .map(|model| model.provider.clone());
1180 let mut diagnostics = Vec::new();
1181
1182 apply_cli_api_key(
1183 Some("sk-selected-provider"),
1184 Some(&selected),
1185 &runtime,
1186 &mut diagnostics,
1187 )
1188 .await
1189 .map_err(|error| format!("runtime API key installation failed: {error}"))?;
1190
1191 assert!(diagnostics.is_empty());
1192 assert!(runtime.has_configured_auth(&selected.provider));
1193 if let Some(unselected_provider) = unselected_provider {
1194 assert!(
1195 !runtime.has_configured_auth(&unselected_provider),
1196 "key must not be stored under an unselected CLI provider"
1197 );
1198 }
1199 }
1200 Ok(())
1201 }
1202
1203 #[tokio::test]
1205 async fn run_pipeline_version_exits_zero() {
1206 let io_state = Arc::new(FakeIo::default());
1207 let factory = Arc::new(FakeRuntimeFactory);
1208 let handler = Arc::new(FakePackageHandler::default());
1209 let output = Arc::new(FakePackageOutput::default());
1210 let dispatcher = Arc::new(DefaultDispatcher::new());
1211 let io = Io::custom(io_state, factory, handler, output, dispatcher);
1212 let result = run_pipeline(vec!["--version".to_owned()], &io).await;
1213 assert_eq!(result, ExitCode::from(0));
1214 }
1215
1216 #[tokio::test]
1217 async fn run_pipeline_unknown_flag_exits_one() {
1218 let io_state = Arc::new(FakeIo::default());
1219 let factory = Arc::new(FakeRuntimeFactory);
1220 let handler = Arc::new(FakePackageHandler::default());
1221 let output = Arc::new(FakePackageOutput::default());
1222 let dispatcher = Arc::new(DefaultDispatcher::new());
1223 let io = Io::custom(io_state, factory, handler, output, dispatcher);
1224 let result = run_pipeline(vec!["-Z".to_owned()], &io).await;
1225 assert_eq!(result, ExitCode::from(1));
1226 }
1227
1228 #[tokio::test]
1229 async fn offline_is_applied_before_package_short_circuit() {
1230 let io_state = Arc::new(FakeIo::default());
1231 let factory = Arc::new(FakeRuntimeFactory);
1232 let handler = Arc::new(FakePackageHandler::default());
1233 let output = Arc::new(FakePackageOutput::default());
1234 let dispatcher = Arc::new(DefaultDispatcher::new());
1235 let io = Io::custom(io_state, factory, handler.clone(), output, dispatcher);
1236
1237 let result = run_pipeline(
1238 vec![
1239 "install".to_owned(),
1240 "example".to_owned(),
1241 "--offline".to_owned(),
1242 ],
1243 &io,
1244 )
1245 .await;
1246
1247 assert_eq!(result, ExitCode::from(0));
1248 assert!(handler.offline.load(std::sync::atomic::Ordering::Relaxed));
1249 }
1250
1251 #[test]
1252 fn unavailable_real_updates_fail_honestly() {
1253 let handler = RealPackageHandler::new(true);
1254 assert_eq!(
1255 handler.refresh_models(),
1256 Err("Cannot refresh model catalogs while offline".to_owned())
1257 );
1258 let error = handler.update_self(false).err();
1259 assert!(
1260 error
1261 .as_deref()
1262 .is_some_and(|error| error.contains("not supported by this build")),
1263 "self-update must fail honestly without an engine: {error:?}"
1264 );
1265 }
1266
1267 struct FakeRuntimeFactory;
1269 impl RuntimeFactory for FakeRuntimeFactory {
1270 fn create(
1271 &self,
1272 _options: RuntimeFactoryOptions,
1273 ) -> BoxFuture<'_, Result<RuntimeHandle, String>> {
1274 async { Err("__fake_factory__".to_owned()) }.boxed()
1275 }
1276 }
1277
1278 #[derive(Default)]
1279 struct FakePackageHandler {
1280 offline: std::sync::atomic::AtomicBool,
1281 }
1282 impl PackageHandler for FakePackageHandler {
1283 fn set_offline(&self, offline: bool) {
1284 self.offline
1285 .store(offline, std::sync::atomic::Ordering::Relaxed);
1286 }
1287 fn install(&self, _s: &str, _l: bool) -> Result<(), String> {
1288 Ok(())
1289 }
1290 fn remove(&self, _s: &str, _l: bool) -> Result<bool, String> {
1291 Ok(true)
1292 }
1293 fn list(&self) -> Result<Vec<ListedPackage>, String> {
1294 Ok(Vec::new())
1295 }
1296 fn is_project_trusted(&self) -> bool {
1297 true
1298 }
1299 fn refresh_models(&self) -> Result<(), String> {
1300 Ok(())
1301 }
1302 fn update_extensions(&self, _s: Option<&str>) -> Result<(), String> {
1303 Ok(())
1304 }
1305 fn update_self(&self, _f: bool) -> Result<bool, String> {
1306 Ok(false)
1307 }
1308 }
1309
1310 #[derive(Default)]
1311 struct FakePackageOutput {
1312 lines: Mutex<Vec<String>>,
1313 }
1314
1315 impl PackageOutput for FakePackageOutput {
1316 fn status(&self, line: &str) {
1317 self.lines
1318 .lock()
1319 .unwrap_or_else(std::sync::PoisonError::into_inner)
1320 .push(line.to_owned());
1321 }
1322 fn status_dim(&self, line: &str) {
1323 self.status(line);
1324 }
1325 fn success(&self, line: &str) {
1326 self.status(line);
1327 }
1328 fn error(&self, line: &str) {
1329 self.status(line);
1330 }
1331 }
1332}