1mod agents;
4mod attachments;
5pub mod components;
6mod config;
7pub mod imports;
8pub mod overview;
9mod restart;
10
11use std::{
12 collections::{HashMap, VecDeque},
13 io::Read as _,
14 path::{Path, PathBuf},
15 sync::{
16 Arc,
17 atomic::{AtomicU64, AtomicUsize, Ordering},
18 },
19 time::Duration,
20};
21
22use anyhow::{Context, Result, anyhow};
23use async_trait::async_trait;
24use config::Config;
25pub use config::{ApprovalPolicy, ConfigOverrides, user_home_path};
26use sha2::{Digest, Sha256};
27pub fn init_user_config() -> anyhow::Result<std::path::PathBuf> {
28 config::Config::init_user_config()
29}
30pub fn update_index_url(workspace: &std::path::Path) -> anyhow::Result<Option<String>> {
31 Ok(config::Config::load(workspace, ConfigOverrides::default())?
32 .update
33 .index_url)
34}
35
36pub use agents::{Endpoint, PI_PROVIDER, WireApi, read_secret};
37pub use restart::{BuildInfo, CONFIG_LAYOUT, build_info, watchdog as restart_watchdog};
38pub use scv_tools::{adapters, delegation};
39
40pub fn agent_command(agent: &str) -> Result<std::process::Command> {
45 let config = Config::load_user(ConfigOverrides::default())?;
46 config.prepare_adapter_homes()?;
47 let adapter = config
48 .adapters()
49 .remove(&format!("agent_{agent}"))
50 .ok_or_else(|| anyhow!("unknown agent {agent}"))?;
51 let executable =
52 scv_tools::adapters::resolve_agent_executable(&adapter.command, &adapter.search_dirs)
53 .ok_or_else(|| {
54 anyhow!(
55 "{agent} is not installed: {:?} was not found on PATH or in ~/.local/bin",
56 adapter.command
57 )
58 })?;
59 let mut command = std::process::Command::new(executable);
60 command.current_dir(config.layout().agent_home(agent));
61 scv_tools::apply_agent_environment(&mut command, &adapter.environment);
62 Ok(command)
63}
64
65pub fn agent_executable(agent: &str) -> Result<Option<PathBuf>> {
67 let config = Config::load_user(ConfigOverrides::default())?;
68 let adapter = config
69 .adapters()
70 .remove(&format!("agent_{agent}"))
71 .ok_or_else(|| anyhow!("unknown agent {agent}"))?;
72 Ok(scv_tools::adapters::resolve_agent_executable(
73 &adapter.command,
74 &adapter.search_dirs,
75 ))
76}
77
78pub fn agent_home(agent: &str) -> Result<PathBuf> {
80 let config = Config::load_user(ConfigOverrides::default())?;
81 config.prepare_adapter_homes()?;
82 let home = config.layout().agent_home(agent);
83 if !home.is_dir() {
84 return Err(anyhow!("unknown agent {agent}"));
85 }
86 Ok(home)
87}
88
89pub fn collect_agent_garbage(
93 agent: Option<&str>,
94 older_than: std::time::Duration,
95 dry_run: bool,
96) -> Result<Vec<(&'static str, scv_tools::conversation::GcReport)>> {
97 let config = Config::load_user(ConfigOverrides::default())?;
98 let markers = config.layout().conversations();
99 let mut reports = Vec::new();
100 for adapter in adapters::ADAPTERS {
101 if agent.is_some_and(|agent| agent != adapter.name) {
102 continue;
103 }
104 let Some(files) = adapter.conversation_files else {
105 continue;
106 };
107 let home = config.layout().agent_home(adapter.name);
108 if !home.is_dir() {
109 continue;
110 }
111 let report =
112 scv_tools::conversation::collect_garbage(&home, files, &markers, older_than, dry_run)
113 .with_context(|| format!("clean {} transcripts", adapter.name))?;
114 reports.push((adapter.name, report));
115 }
116 Ok(reports)
117}
118
119pub fn conversation_age(value: &str) -> std::result::Result<std::time::Duration, String> {
121 scv_tools::conversation::parse_age(value)
122}
123
124fn key_store_home(agent: &str) -> Result<PathBuf> {
125 scv_tools::adapters::adapter(agent).ok_or_else(|| anyhow!("unknown agent {agent}"))?;
126 agent_home(agent)
127}
128
129pub fn store_agent_key(agent: &str, store: adapters::KeyStore, key: &str) -> Result<Vec<String>> {
131 agents::store_key(store, &key_store_home(agent)?, key)
132}
133
134pub fn agent_stored_status(agent: &str, store: adapters::KeyStore) -> Result<(bool, Vec<String>)> {
137 agents::stored_status(store, &key_store_home(agent)?)
138}
139
140pub fn remove_agent_credentials(agent: &str, store: adapters::KeyStore) -> Result<Vec<String>> {
142 agents::remove_stored(store, &key_store_home(agent)?)
143}
144
145pub fn configure_pi_endpoint(endpoint: &Endpoint, key: &str) -> Result<Vec<String>> {
147 agents::configure_pi_endpoint(&pi_agent_dir()?, endpoint, key)
148}
149
150pub fn import_pi_from_scv_provider() -> Result<Vec<String>> {
154 let config = Config::load_user(ConfigOverrides::default())?;
155 let provider = &config.provider;
156 let key = scv_provider_key(provider)?;
157 let endpoint = Endpoint {
158 base_url: provider.base_url.clone(),
159 api: WireApi::Responses,
160 model: provider.model.clone(),
161 };
162 let mut notes = agents::configure_pi_endpoint(&pi_agent_dir()?, &endpoint, &key)?;
163 imports::record(
164 &config.layout(),
165 "pi",
166 imports::Source::ScvProvider,
167 provider_digest("pi", &config, &key)?,
168 )?;
169 if !provider.headers.is_empty() {
170 notes.push(
171 "Note: SCV's provider sends extra headers, which were not copied; add them to \
172 pi's models.json if the endpoint needs them"
173 .into(),
174 );
175 }
176 Ok(notes)
177}
178
179fn scv_provider_key(provider: &config::ProviderConfig) -> Result<String> {
182 if provider.kind != "openai-compatible" {
183 return Err(anyhow!("SCV's provider is not openai-compatible"));
184 }
185 match (&provider.api_key, &provider.api_key_env) {
186 (Some(key), _) if !key.trim().is_empty() => Ok(key.trim().to_owned()),
187 (_, Some(variable)) => std::env::var(variable)
188 .ok()
189 .filter(|key| !key.trim().is_empty())
190 .ok_or_else(|| {
191 anyhow!("SCV's provider reads its key from ${variable}, which is not set here")
192 }),
193 _ => Err(anyhow!("SCV's provider has no API key configured")),
194 }
195}
196
197fn provider_digest(agent: &str, config: &Config, key: &str) -> Result<String> {
200 let provider = &config.provider;
201 let mut headers: Vec<_> = provider.headers.iter().collect();
202 headers.sort();
203 match agent {
204 "pi" => imports::digest_value(&(&provider.base_url, &provider.model, key)),
205 _ => imports::digest_value(&(
206 &provider.base_url,
207 &provider.model,
208 key,
209 &provider.wire_api,
210 provider.timeout_seconds,
211 headers,
212 config.hosted_web_search(),
213 )),
214 }
215}
216
217pub fn agent_import_status(agent: &str) -> Result<Option<String>> {
220 let config = Config::load_user(ConfigOverrides::default())?;
221 let status = imports::check(&config.layout(), agent, || {
222 let key = scv_provider_key(&config.provider).ok()?;
223 provider_digest(agent, &config, &key).ok()
224 })?;
225 Ok(status.map(|status| status.describe(agent, imports::now())))
226}
227
228pub fn import_scv_from_scv_provider() -> Result<Vec<String>> {
231 let config = Config::load_user(ConfigOverrides::default())?;
232 config.prepare_adapter_homes()?;
233 let provider = &config.provider;
234 let key = scv_provider_key(provider)?;
235 let digest = provider_digest("scv", &config, &key)?;
236 let notes = agents::configure_scv_child(
237 &agent_home("scv")?,
238 &agents::ScvChildProvider {
239 wire_api: &provider.wire_api,
240 model: &provider.model,
241 base_url: &provider.base_url,
242 timeout_seconds: provider.timeout_seconds,
243 headers: &provider.headers,
244 hosted_web_search: config.hosted_web_search(),
245 },
246 &key,
247 )?;
248 imports::record(
249 &config.layout(),
250 "scv",
251 imports::Source::ScvProvider,
252 digest,
253 )?;
254 Ok(notes)
255}
256
257fn pi_agent_dir() -> Result<PathBuf> {
258 let descriptor =
259 scv_tools::adapters::adapter("pi").ok_or_else(|| anyhow!("unknown agent pi"))?;
260 let scv_tools::adapters::Status::Stored(scv_tools::adapters::KeyStore::Pi { dir }) =
261 descriptor.status
262 else {
263 return Err(anyhow!("pi has no SCV-managed store"));
264 };
265 Ok(agent_home("pi")?.join(dir))
266}
267
268pub fn import_codex(source: &Path) -> Result<Vec<String>> {
272 let config = Config::load_user(ConfigOverrides::default())?;
273 config.prepare_adapter_homes()?;
274 let layout = config.layout();
275 let notes = agents::import_codex(source, &layout.agent_home("codex"))?;
276 record_file_import(&layout, "codex", source, agents::codex_copied_files(source))?;
277 Ok(notes)
278}
279
280fn record_file_import(
283 layout: &scv_client::Layout,
284 agent: &str,
285 source: &Path,
286 files: Vec<String>,
287) -> Result<()> {
288 let dir = std::fs::canonicalize(source).unwrap_or_else(|_| source.to_owned());
289 let digest = imports::digest_files(&dir, &files)?;
290 imports::record(layout, agent, imports::Source::Files { dir, files }, digest)
291}
292
293pub fn import_grok(source: &Path) -> Result<Vec<String>> {
297 let config = Config::load_user(ConfigOverrides::default())?;
298 config.prepare_adapter_homes()?;
299 let descriptor =
300 scv_tools::adapters::adapter("grok").ok_or_else(|| anyhow!("unknown agent grok"))?;
301 let grok_home = descriptor
302 .home_environment
303 .iter()
304 .find(|(variable, _)| *variable == "GROK_HOME")
305 .map(|(_, relative)| *relative)
306 .ok_or_else(|| anyhow!("grok has no GROK_HOME in its agent home"))?;
307 let layout = config.layout();
308 let notes = agents::import_grok(source, &layout.agent_home("grok").join(grok_home))?;
309 record_file_import(&layout, "grok", source, vec!["config.toml".into()])?;
310 Ok(notes)
311}
312
313pub fn service_name() -> anyhow::Result<String> {
315 if std::env::var_os("SCV_HOME").is_none() {
316 return Ok("scv.service".into());
317 }
318 let home = user_home_path().ok_or_else(|| anyhow!("cannot determine SCV instance home"))?;
319 let digest = Sha256::digest(home.to_string_lossy().as_bytes());
320 let suffix = digest[..8]
321 .iter()
322 .map(|byte| format!("{byte:02x}"))
323 .collect::<String>();
324 Ok(format!("scv-{suffix}.service"))
325}
326
327pub fn service_unit_path() -> anyhow::Result<std::path::PathBuf> {
328 let config =
329 dirs::config_dir().ok_or_else(|| anyhow!("cannot determine XDG config directory"))?;
330 Ok(config.join("systemd/user").join(service_name()?))
331}
332use scv_core::{
333 AgentError, AgentRuntime, ApprovalGate, ApprovalRequest, BudgetContextPolicy, CoreEvent,
334 EventSink, Message, ToolRegistry, ToolRisk, TurnInput,
335};
336use scv_protocol::{
337 Attachment, ClientMessage, DaemonCommand, DaemonStatus, DelegationInfo, DelegationSummary,
338 ORIGIN_BACKGROUND, PROTOCOL_VERSION, PeerInfo, QueueEntry, ServerEvent, TurnOrigin, Usage,
339};
340use scv_provider_openai::OpenAiProvider;
341use scv_tools::{
342 DelegationContext, SkillMap,
343 background::{self, BackgroundJobs},
344 builtin_registry,
345 delegation::{self as delegations, DelegationRegistry},
346};
347use tokio::{
348 io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt, BufReader},
349 net::{UnixListener, UnixStream},
350 sync::{Mutex, OwnedSemaphorePermit, Semaphore, mpsc, oneshot},
351 task::JoinHandle,
352};
353use tokio_util::sync::CancellationToken;
354use tokio_util::task::TaskTracker;
355use uuid::Uuid;
356
357const PROMPT_LIMIT_BYTES: usize = 256 * 1024;
358const OUTPUT_QUEUE_CAPACITY: usize = 256;
359const OUTPUT_QUEUE_MIN_BYTES: usize = 16 * 1024 * 1024;
360const SHUTDOWN_GRACE: Duration = Duration::from_secs(3);
361const MAX_QUEUE_ITEMS: usize = 64;
362const MAX_QUEUE_BYTES: usize = 4 * 1024 * 1024;
363
364pub async fn run_stdio(overrides: ConfigOverrides) -> Result<()> {
365 let stdin = tokio::io::stdin();
366 let stdout = tokio::io::stdout();
367 let tasks = TaskTracker::new();
368 let registry = instance_delegations()?;
369 tokio::spawn(reconcile_delegations(Arc::clone(®istry)));
372 let result = run_managed(
373 stdin,
374 stdout,
375 overrides,
376 None,
377 registry,
378 CancellationToken::new(),
379 tasks.clone(),
380 )
381 .await;
382 tasks.close();
383 tasks.wait().await;
384 result
385}
386
387pub fn default_socket_path() -> Result<PathBuf> {
389 scv_client::default_socket_path()
390}
391
392pub async fn run_socket(path: &Path, overrides: ConfigOverrides) -> Result<()> {
394 if let Some(parent) = path.parent() {
395 tokio::fs::create_dir_all(parent)
396 .await
397 .context("create SCV socket directory")?;
398 #[cfg(unix)]
399 {
400 use std::os::unix::fs::PermissionsExt;
401 std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
402 .context("secure SCV socket directory")?;
403 }
404 }
405 let _lock = SocketLock::acquire(path)?;
406 if let Ok(strays) = scv_client::Layout::from_env().and_then(|layout| layout.strays()) {
409 for stray in strays.into_iter().filter(|stray| stray.legacy) {
410 tracing::warn!(
411 "{} is from an older SCV layout and is not used; see `scv config show`",
412 stray.path.display()
413 );
414 }
415 }
416 if path.exists() {
417 if UnixStream::connect(path).await.is_ok() {
418 return Err(anyhow!(
419 "SCV server is already running at {}",
420 path.display()
421 ));
422 }
423 use std::os::unix::fs::FileTypeExt;
424 if !std::fs::symlink_metadata(path)?.file_type().is_socket() {
425 return Err(anyhow!(
426 "refusing to remove a non-socket at SCV socket path"
427 ));
428 }
429 tokio::fs::remove_file(path)
430 .await
431 .with_context(|| format!("remove stale SCV socket {}", path.display()))?;
432 }
433 let listener = UnixListener::bind(path)
434 .with_context(|| format!("bind SCV server socket {}", path.display()))?;
435 #[cfg(unix)]
436 {
437 use std::os::unix::fs::PermissionsExt;
438 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
439 .context("secure SCV socket")?;
440 }
441 let home =
442 config::user_home_path().ok_or_else(|| anyhow!("cannot determine SCV instance home"))?;
443 let hub = scv_channels::hub::Hub::new(Some(restart::last_owner_path(&home)));
444 let startup = restart::startup(&home, &hub);
447 let components = Arc::new(Mutex::new(components::Components::with_hub(
448 path.to_owned(),
449 std::env::current_dir()?,
450 Arc::clone(&hub),
451 )));
452 let registry = instance_delegations()?;
453 if !delegations::become_child_subreaper() {
455 tracing::debug!("SCV daemon is not a child subreaper on this platform");
456 }
457 let cancellation = CancellationToken::new();
458 let restarter = restart::Restarter::new(
459 home.clone(),
460 hub,
461 Arc::clone(®istry),
462 &components,
463 cancellation.clone(),
464 );
465 components
466 .lock()
467 .await
468 .set_restarter(Arc::clone(&restarter));
469 let notices = tokio::spawn(restart::announce(
470 home.clone(),
471 startup,
472 restarter.notifier().clone(),
473 cancellation.clone(),
474 ));
475 let _notices_abort = AbortGuard(notices.abort_handle());
476 let monitor = tokio::spawn(restart::monitor(
477 restarter.notifier().clone(),
478 cancellation.clone(),
479 ));
480 let _monitor_abort = AbortGuard(monitor.abort_handle());
481 let delegation_registry = Arc::clone(®istry);
482 let delegation_cancel = cancellation.clone();
483 let mut delegation_task = tokio::spawn(async move {
484 let mut interval = tokio::time::interval(DELEGATION_RECONCILE_INTERVAL);
486 loop {
487 tokio::select! {
488 biased;
489 _ = delegation_cancel.cancelled() => break,
490 _ = interval.tick() => {
491 reconcile_delegations(Arc::clone(&delegation_registry)).await;
492 let zombies = delegations::reap_orphaned_zombies();
493 if zombies > 0 {
494 tracing::debug!("Reaped {zombies} exited orphan processes");
495 }
496 }
497 }
498 }
499 });
500 let _delegation_abort = AbortGuard(delegation_task.abort_handle());
501 let tasks = TaskTracker::new();
502 let mut clients = tokio::task::JoinSet::new();
503 let refresh_components = components.clone();
504 let refresh_cancel = cancellation.clone();
505 let mut refresh_task = tokio::spawn(async move {
506 let mut refresh = tokio::time::interval(Duration::from_secs(2));
507 loop {
508 tokio::select! {
509 biased;
510 _ = refresh_cancel.cancelled() => break,
511 _ = refresh.tick() => {
512 tokio::select! {
513 biased;
514 _ = refresh_cancel.cancelled() => break,
515 result = async { refresh_components.lock().await.reconcile().await } => {
516 if result.is_err() { tracing::warn!("Component account discovery failed"); }
517 }
518 }
519 }
520 }
521 }
522 });
523 let _refresh_abort = AbortGuard(refresh_task.abort_handle());
524 let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
525 let result = loop {
526 tokio::select! {
527 accepted = listener.accept() => {
528 let (stream, _) = match accepted { Ok(value) => value, Err(error) => break Err(error.into()) };
529 let child_overrides = overrides.clone();
530 let components = components.clone();
531 let registry = Arc::clone(®istry);
532 let cancellation = cancellation.clone();
533 let tasks = tasks.clone();
534 clients.spawn(async move {
535 let (reader, writer) = stream.into_split();
536 if run_managed(reader, writer, child_overrides, Some(components), registry, cancellation, tasks).await.is_err() {
537 tracing::warn!("SCV socket client stopped");
538 }
539 });
540 }
541 _ = clients.join_next(), if !clients.is_empty() => {},
542 _ = tokio::signal::ctrl_c() => break Ok(()),
543 _ = terminate.recv() => break Ok(()),
544 }
545 };
546 drop(listener);
547 cancellation.cancel();
548 let _ = (&mut refresh_task).await;
549 let _ = (&mut delegation_task).await;
550 components.lock().await.shutdown().await;
551 if tokio::time::timeout(Duration::from_secs(8), async {
552 while clients.join_next().await.is_some() {}
553 })
554 .await
555 .is_err()
556 {
557 clients.abort_all();
558 while clients.join_next().await.is_some() {}
559 }
560 tasks.close();
561 tasks.wait().await;
562 let _ = tokio::fs::remove_file(path).await;
563 restart::clean_shutdown(&home);
564 result
565}
566
567const DELEGATION_RECONCILE_INTERVAL: Duration = Duration::from_secs(60);
568
569fn instance_delegations() -> Result<Arc<DelegationRegistry>> {
571 let home =
572 config::user_home_path().ok_or_else(|| anyhow!("cannot determine SCV instance home"))?;
573 Ok(Arc::new(DelegationRegistry::new(&home)))
574}
575
576async fn reconcile_delegations(registry: Arc<DelegationRegistry>) {
578 let report = registry.reconcile().await;
579 if !report.reaped.is_empty() {
580 tracing::info!(
581 "Reaped {} orphaned delegations: {}",
582 report.reaped.len(),
583 report.reaped.join(", ")
584 );
585 }
586 if report.removed > 0 {
587 tracing::debug!(
588 "Removed {} delegation records whose processes had exited",
589 report.removed
590 );
591 }
592 if report.stale_markers > 0 {
593 tracing::debug!(
594 "Removed {} conversation markers whose SCV process had exited",
595 report.stale_markers
596 );
597 }
598}
599
600enum ControlFailure {
602 Delegation(String),
604 Restart(String),
606 Component,
607}
608
609async fn daemon_control(
611 components: &Arc<Mutex<components::Components>>,
612 registry: &DelegationRegistry,
613 command: DaemonCommand,
614) -> std::result::Result<DaemonStatus, ControlFailure> {
615 let mut killed = Vec::new();
616 let restarter = components.lock().await.restarter();
617 if let DaemonCommand::RestartWhenIdle { .. } = &command {
618 let restarter = restarter.as_ref().ok_or_else(|| {
619 ControlFailure::Restart("only the SCV daemon can schedule its restart".into())
620 })?;
621 restarter
622 .request(command.clone())
623 .await
624 .map_err(ControlFailure::Restart)?;
625 }
626 let listing = match &command {
627 DaemonCommand::Delegations { all } => Some(*all),
628 DaemonCommand::DelegationKill { handle, orphans } => {
629 if handle.is_none() && !orphans {
630 return Err(ControlFailure::Delegation(
631 "name a delegation handle or ask for orphans".into(),
632 ));
633 }
634 if *orphans {
635 let report = registry.reconcile().await;
636 killed.extend(report.reaped);
637 }
638 if let Some(handle) = handle {
639 registry
640 .kill(handle)
641 .await
642 .map_err(ControlFailure::Delegation)?;
643 killed.push(handle.clone());
644 }
645 Some(true)
646 }
647 _ => None,
648 };
649 let mut status = components
650 .lock()
651 .await
652 .control(command)
653 .await
654 .map_err(|_| ControlFailure::Component)?;
655 let running = registry.list(false);
656 status.delegations = DelegationSummary {
657 active: running.len() as u64,
658 reaped: registry.reaped_total(),
659 entries: match listing {
660 Some(true) => registry.list(true),
661 Some(false) => running,
662 None => Vec::new(),
663 }
664 .into_iter()
665 .map(|entry| DelegationInfo {
666 handle: entry.record.handle,
667 agent: entry.record.agent,
668 session: entry.record.session,
669 depth: entry.record.depth,
670 pid: entry.record.process.pid,
671 owner_pid: entry.record.owner.pid,
672 processes: u32::try_from(entry.processes).unwrap_or(u32::MAX),
673 cwd: entry.record.cwd.display().to_string(),
674 started_unix_seconds: entry.record.started_unix,
675 orphaned: entry.orphaned,
676 conversation: entry.record.conversation,
677 turn: entry.record.turn,
678 })
679 .collect(),
680 killed,
681 };
682 status.restart = restarter.and_then(|restarter| restarter.info());
683 Ok(status)
684}
685
686async fn run_managed<R, W>(
687 reader: R,
688 writer: W,
689 overrides: ConfigOverrides,
690 components: Option<Arc<Mutex<components::Components>>>,
691 registry: Arc<DelegationRegistry>,
692 cancellation: CancellationToken,
693 tasks: TaskTracker,
694) -> Result<()>
695where
696 R: tokio::io::AsyncRead + Unpin,
697 W: tokio::io::AsyncWrite + Unpin + Send + 'static,
698{
699 let initial_output_bytes =
700 output_queue_bytes(Config::default().protocol.max_server_frame_bytes)?;
701 let (output_tx, mut output_rx) = outbound_channel(initial_output_bytes);
702 let mut writer_task = tasks.spawn(async move {
703 let mut writer = writer;
704 while let Some(frame) = output_rx.recv().await {
705 writer.write_all(&frame.bytes).await?;
706 writer.write_all(b"\n").await?;
707 writer.flush().await?;
708 }
709 Ok::<(), std::io::Error>(())
710 });
711 let _writer_abort = AbortGuard(writer_task.abort_handle());
712 let (done_tx, mut done_rx) = mpsc::channel::<TurnDone>(4);
713 let approvals = Arc::new(ApprovalBroker::default());
714 let mut reader = BufReader::new(reader);
715 let mut frames = FrameBuffer::default();
716 let mut initialized = false;
717 let mut session: Option<Session> = None;
718 let mut activity: Option<restart::SessionTracker> = None;
721 let mut active: Option<ActiveTurn> = None;
722 let mut background_rx: Option<mpsc::UnboundedReceiver<()>> = None;
724 let mut background_ready = false;
725 let mut fatal = false;
726 let mut writer_finished = false;
727
728 let loop_result: Result<()> = async {
729 loop {
730 if let Some(activity) = &activity {
731 activity.set_busy(active.is_some() || background_ready);
732 }
733 let frame_limit = session.as_ref().map_or_else(
734 || Config::default().protocol.max_client_frame_bytes,
735 |value| value.config.protocol.max_client_frame_bytes,
736 );
737 tokio::select! {
738 _ = cancellation.cancelled() => break,
739 read = frames.read(&mut reader, frame_limit) => {
740 let frame = match read.context("read protocol input")? {
741 FrameRead::Eof => {
742 if let Some(active) = &active { active.cancellation.cancel(); }
743 break;
744 }
745 FrameRead::TooLarge => {
746 send_error(&output_tx, "", "invalid_request", "client frame exceeds configured limit", false, server_frame_limit(&session)).await?;
747 continue;
748 }
749 FrameRead::Frame(frame) => frame,
750 };
751 if frame.is_empty() {
752 send_error(&output_tx, "", "invalid_json", "protocol frame is empty", false, server_frame_limit(&session)).await?;
753 continue;
754 }
755 let message = match serde_json::from_slice::<ClientMessage>(&frame) {
756 Ok(message) => message,
757 Err(error) => {
758 send_error(&output_tx, "", "invalid_json", &format!("invalid protocol JSON: {error}"), false, server_frame_limit(&session)).await?;
759 continue;
760 }
761 };
762 match message {
763 ClientMessage::Initialize { request_id, protocol_version, .. } => {
764 if initialized {
765 send_error(&output_tx, &request_id, "invalid_request", "connection is already initialized", false, server_frame_limit(&session)).await?;
766 continue;
767 }
768 if protocol_version != PROTOCOL_VERSION {
769 send_error(&output_tx, &request_id, "version_mismatch", &format!("server supports protocol {PROTOCOL_VERSION}"), true, server_frame_limit(&session)).await?;
770 fatal = true;
771 break;
772 }
773 initialized = true;
774 send_event(&output_tx, ServerEvent::Initialized {
775 request_id,
776 protocol_version: PROTOCOL_VERSION,
777 server: PeerInfo { name: "scv-server".into(), version: env!("CARGO_PKG_VERSION").into() },
778 }, Config::default().protocol.max_server_frame_bytes).await?;
779 }
780 other if !initialized => {
781 send_error(&output_tx, other.request_id(), "not_initialized", "initialize must be the first message", false, server_frame_limit(&session)).await?;
782 }
783 ClientMessage::DaemonControl { request_id, command } => {
784 if let Some(components) = &components {
785 let result = tokio::select! {
786 biased;
787 _ = cancellation.cancelled() => break,
788 result = daemon_control(components, ®istry, command) => result,
789 };
790 match result {
791 Ok(status) => send_event(&output_tx, ServerEvent::DaemonStatus { request_id, status }, server_frame_limit(&session)).await?,
792 Err(ControlFailure::Delegation(message)) => send_error(&output_tx, &request_id, "delegation_error", &message, false, server_frame_limit(&session)).await?,
793 Err(ControlFailure::Restart(message)) => send_error(&output_tx, &request_id, "restart_error", &message, false, server_frame_limit(&session)).await?,
794 Err(ControlFailure::Component) => send_error(&output_tx, &request_id, "component_error", "Component operation failed; check account credentials, private file permissions and absolute workspace", false, server_frame_limit(&session)).await?,
795 }
796 } else {
797 send_error(&output_tx, &request_id, "unsupported", "Component management requires the daemon socket", false, server_frame_limit(&session)).await?;
798 }
799 }
800 ClientMessage::SessionStart { request_id, cwd, provider, model, base_url, no_tools, delegation_depth, channel, auto_approve } => {
801 if session.is_some() {
802 send_error(&output_tx, &request_id, "invalid_request", "this connection already has a session", false, server_frame_limit(&session)).await?;
803 continue;
804 }
805 if channel.as_deref().is_some_and(|name| !valid_channel_name(name)) {
806 send_error(&output_tx, &request_id, "invalid_request", "channel must be a short name without control characters", false, server_frame_limit(&session)).await?;
807 continue;
808 }
809 let client = SessionClient { channel, auto_approve: auto_approve.unwrap_or(false) };
810 let session_overrides = ConfigOverrides {
811 provider: provider.or_else(|| overrides.provider.clone()),
812 model: model.or_else(|| overrides.model.clone()),
813 base_url: base_url.or_else(|| overrides.base_url.clone()),
814 approval_policy: overrides.approval_policy,
815 no_tools: no_tools.unwrap_or(overrides.no_tools),
816 };
817 match build_session(&cwd, session_overrides, delegation_depth.unwrap_or(0), ®istry, client).await {
818 Ok((new_session, finished)) => {
819 background_rx = finished;
820 output_tx.ensure_capacity(output_queue_bytes(
821 new_session.config.protocol.max_server_frame_bytes,
822 )?)?;
823 let event = ServerEvent::SessionStarted {
824 request_id,
825 session_id: new_session.id.clone(),
826 cwd: new_session.workspace.display().to_string(),
827 model: new_session.runtime.model().to_owned(),
828 context_max_tokens: new_session.config.context.max_tokens,
829 max_server_frame_bytes: new_session.config.protocol.max_server_frame_bytes,
830 max_transcript_bytes: new_session.config.tui.max_transcript_bytes,
831 max_transcript_items: new_session.config.tui.max_transcript_items,
832 max_prompt_history_bytes: new_session.config.tui.max_prompt_history_bytes,
833 max_prompt_history_items: new_session.config.tui.max_prompt_history_items,
834 };
835 send_event(&output_tx, event, new_session.config.protocol.max_server_frame_bytes).await?;
836 send_event(&output_tx, ServerEvent::QueueSnapshot {
837 request_id: None,
838 session_id: new_session.id.clone(),
839 seq: next_seq(&new_session.seq),
840 entries: new_session.queue.lock().await.iter().cloned().collect(),
841 paused: new_session.paused.load(Ordering::Acquire),
842 }, new_session.config.protocol.max_server_frame_bytes).await?;
843 activity = Some(restart::SessionTracker::new(&new_session.id, new_session.background.as_ref()));
844 session = Some(new_session);
845 }
846 Err(error) => {
847 send_error(&output_tx, &request_id, "invalid_request", &error.to_string(), false, server_frame_limit(&session)).await?;
848 }
849 }
850 }
851 ClientMessage::SessionAttach { request_id, .. } => {
852 send_error(&output_tx, &request_id, "unsupported", "session attach requires the shared socket server", false, server_frame_limit(&session)).await?;
853 }
854 ClientMessage::TurnStart { request_id, session_id, prompt, attachments } => {
855 let Some(current) = session.as_ref() else {
856 send_error(&output_tx, &request_id, "session_not_found", "start a session first", false, server_frame_limit(&session)).await?;
857 continue;
858 };
859 if current.id != session_id {
860 send_error(&output_tx, &request_id, "session_not_found", "session id does not match", false, server_frame_limit(&session)).await?;
861 continue;
862 }
863 if (prompt.trim().is_empty() && attachments.is_empty()) || prompt.len() > PROMPT_LIMIT_BYTES {
864 send_error(&output_tx, &request_id, "invalid_request", "prompt must be non-empty and no larger than 256 KiB", false, server_frame_limit(&session)).await?;
865 continue;
866 }
867 if let Err(message) = attachments::validate(&attachments) {
868 send_error(&output_tx, &request_id, "invalid_request", &message, false, server_frame_limit(&session)).await?;
869 continue;
870 }
871 if active.is_some() {
872 let entry = match current.enqueue(prompt, request_id.clone(), attachments).await {
873 Ok(entry) => entry,
874 Err(code) => { send_error(&output_tx, &request_id, code, "session queue limit reached", false, server_frame_limit(&session)).await?; continue; }
875 };
876 let position = current.queue.lock().await.len().saturating_sub(1);
877 send_event(&output_tx, ServerEvent::QueueEnqueued {
878 request_id, session_id: current.id.clone(), seq: next_seq(¤t.seq), entry, position,
879 }, current.config.protocol.max_server_frame_bytes).await?;
880 continue;
881 }
882 let starter = TurnStarter { output: &output_tx, approvals: &approvals, done: &done_tx, tasks: &tasks, cancellation: &cancellation };
883 let input = current.turn_input(&prompt, &attachments);
884 active = Some(starter.start(current, Uuid::new_v4().to_string(), request_id, input, None).await?);
885 }
886 ClientMessage::QueueUpdate { request_id, session_id, queue_id, revision, prompt } => {
887 let Some(current) = session.as_ref() else { send_error(&output_tx, &request_id, "session_not_found", "start a session first", false, server_frame_limit(&session)).await?; continue; };
888 if current.id != session_id { send_error(&output_tx, &request_id, "session_not_found", "session id does not match", false, server_frame_limit(&session)).await?; continue; }
889 if prompt.trim().is_empty() || prompt.len() > PROMPT_LIMIT_BYTES { send_error(&output_tx, &request_id, "invalid_request", "prompt must be non-empty and no larger than 256 KiB", false, server_frame_limit(&session)).await?; continue; }
890 match current.update_queue(&queue_id, revision, prompt).await {
891 Ok(entry) => send_event(&output_tx, ServerEvent::QueueUpdated { request_id, session_id: current.id.clone(), seq: next_seq(¤t.seq), entry }, current.config.protocol.max_server_frame_bytes).await?,
892 Err(code) => send_error(&output_tx, &request_id, code, "queue entry was not found or revision is stale", false, server_frame_limit(&session)).await?,
893 }
894 }
895 ClientMessage::QueueMove { request_id, session_id, queue_id, revision, before_queue_id } => {
896 let Some(current) = session.as_ref() else { send_error(&output_tx, &request_id, "session_not_found", "start a session first", false, server_frame_limit(&session)).await?; continue; };
897 match current.move_queue(&session_id, &queue_id, revision, before_queue_id).await {
898 Ok((id, rev, pos)) => send_event(&output_tx, ServerEvent::QueueMoved { request_id, session_id: current.id.clone(), seq: next_seq(¤t.seq), queue_id: id, position: pos, revision: rev }, current.config.protocol.max_server_frame_bytes).await?,
899 Err(code) => send_error(&output_tx, &request_id, code, "queue entry was not found or revision is stale", false, server_frame_limit(&session)).await?,
900 }
901 }
902 ClientMessage::QueueRemove { request_id, session_id, queue_id, revision } => {
903 let Some(current) = session.as_ref() else { send_error(&output_tx, &request_id, "session_not_found", "start a session first", false, server_frame_limit(&session)).await?; continue; };
904 match current.remove_queue(&session_id, &queue_id, revision).await {
905 Ok((id, rev)) => send_event(&output_tx, ServerEvent::QueueRemoved { request_id, session_id: current.id.clone(), seq: next_seq(¤t.seq), queue_id: id, revision: rev }, current.config.protocol.max_server_frame_bytes).await?,
906 Err(code) => send_error(&output_tx, &request_id, code, "queue entry was not found or revision is stale", false, server_frame_limit(&session)).await?,
907 }
908 }
909 ClientMessage::SessionPause { request_id, session_id, paused } => {
910 let Some(current) = session.as_ref() else { send_error(&output_tx, &request_id, "session_not_found", "start a session first", false, server_frame_limit(&session)).await?; continue; };
911 if current.id != session_id { send_error(&output_tx, &request_id, "session_not_found", "session id does not match", false, server_frame_limit(&session)).await?; continue; }
912 current.paused.store(paused, Ordering::Release);
913 send_event(&output_tx, ServerEvent::SessionPaused { request_id, session_id: current.id.clone(), seq: next_seq(¤t.seq), paused }, current.config.protocol.max_server_frame_bytes).await?;
914 }
915 ClientMessage::TurnCancel { request_id, session_id, turn_id } => {
916 match (&session, &active) {
917 (Some(current), Some(running)) if current.id == session_id && running.turn_id == turn_id => running.cancellation.cancel(),
918 _ => send_error(&output_tx, &request_id, "turn_not_found", "active turn was not found", false, server_frame_limit(&session)).await?,
919 }
920 }
921 ClientMessage::ApprovalResolve { request_id, session_id, approval_id, approved } => {
922 if session.as_ref().is_none_or(|current| current.id != session_id) {
923 send_error(&output_tx, &request_id, "session_not_found", "session id does not match", false, server_frame_limit(&session)).await?;
924 } else if !approvals.resolve(&approval_id, approved).await {
925 send_error(&output_tx, &request_id, "approval_not_found", "approval was not found or already resolved", false, server_frame_limit(&session)).await?;
926 }
927 }
928 ClientMessage::SessionClear { request_id, session_id } => {
929 let Some(current) = session.as_ref() else {
930 send_error(&output_tx, &request_id, "session_not_found", "session was not found", false, server_frame_limit(&session)).await?;
931 continue;
932 };
933 if current.id != session_id {
934 send_error(&output_tx, &request_id, "session_not_found", "session id does not match", false, server_frame_limit(&session)).await?;
935 } else if active.is_some() {
936 send_error(&output_tx, &request_id, "turn_active", "cancel the active turn before clearing", false, server_frame_limit(&session)).await?;
937 } else {
938 current.history.lock().await.clear();
939 current.queue.lock().await.clear();
940 send_event(&output_tx, ServerEvent::SessionCleared {
941 request_id,
942 session_id: current.id.clone(),
943 seq: next_seq(¤t.seq),
944 }, current.config.protocol.max_server_frame_bytes).await?;
945 send_event(&output_tx, ServerEvent::QueueSnapshot { request_id: None, session_id: current.id.clone(), seq: next_seq(¤t.seq), entries: Vec::new(), paused: current.paused.load(Ordering::Acquire) }, current.config.protocol.max_server_frame_bytes).await?;
946 }
947 }
948 }
949 }
950 writer = &mut writer_task => {
951 writer_finished = true;
952 writer.context("join protocol writer")??;
953 break;
954 }
955 Some(()) = recv_background(&mut background_rx) => {
956 background_ready = true;
957 if active.is_none()
958 && let Some(current) = session.as_ref()
959 && !current.paused.load(Ordering::Acquire)
960 && current.queue.lock().await.is_empty()
961 {
962 let starter = TurnStarter { output: &output_tx, approvals: &approvals, done: &done_tx, tasks: &tasks, cancellation: &cancellation };
963 active = starter.report_background(current).await?;
964 background_ready = active.is_some();
965 }
966 }
967 done = done_rx.recv(), if active.is_some() => {
968 if let Some(done) = done {
969 if let Some(current) = session.as_ref() {
970 let seq = next_seq(¤t.seq);
971 let event = match done.result {
972 Ok(outcome) => ServerEvent::TurnCompleted {
973 request_id: done.request_id,
974 session_id: done.session_id,
975 turn_id: done.turn_id,
976 seq,
977 steps: outcome.steps,
978 usage: Usage { input_tokens: outcome.usage.input_tokens, output_tokens: outcome.usage.output_tokens },
979 origin: done.origin,
980 },
981 Err(AgentError::Cancelled) => ServerEvent::TurnCancelled {
982 request_id: done.request_id,
983 session_id: done.session_id,
984 turn_id: done.turn_id,
985 seq,
986 origin: done.origin,
987 },
988 Err(error) => ServerEvent::TurnFailed {
989 request_id: done.request_id,
990 session_id: done.session_id,
991 turn_id: done.turn_id,
992 seq,
993 code: error.code().into(),
994 message: error.to_string(),
995 origin: done.origin,
996 },
997 };
998 send_event(&output_tx, event, current.config.protocol.max_server_frame_bytes).await?;
999 }
1000 if let Some(mut active) = active.take() {
1001 let _ = (&mut active.task).await;
1002 }
1003 if let Some(current) = session.as_ref()
1004 && !current.paused.load(Ordering::Acquire)
1005 && let Some(entry) = current.queue.lock().await.pop_front()
1006 {
1007 let turn_id = Uuid::new_v4().to_string();
1008 send_event(&output_tx, ServerEvent::QueueDequeued {
1009 request_id: entry.submitter.clone(),
1010 session_id: current.id.clone(),
1011 seq: next_seq(¤t.seq),
1012 queue_id: entry.queue_id,
1013 turn_id: turn_id.clone(),
1014 }, current.config.protocol.max_server_frame_bytes).await?;
1015 let starter = TurnStarter { output: &output_tx, approvals: &approvals, done: &done_tx, tasks: &tasks, cancellation: &cancellation };
1016 let input = current.turn_input(&entry.prompt, &entry.attachments);
1017 active = Some(starter.start(current, turn_id, entry.submitter, input, None).await?);
1018 }
1019 if active.is_none() && background_ready
1021 && let Some(current) = session.as_ref()
1022 {
1023 let starter = TurnStarter { output: &output_tx, approvals: &approvals, done: &done_tx, tasks: &tasks, cancellation: &cancellation };
1024 active = starter.report_background(current).await?;
1025 background_ready = active.is_some();
1026 }
1027 }
1028 }
1029 }
1030 }
1031 Ok(())
1032 }
1033 .await;
1034
1035 if let Some(active) = active.take() {
1036 shutdown_active_turn(active, SHUTDOWN_GRACE).await;
1037 }
1038 drop(output_tx);
1039 let writer_result = if writer_finished {
1040 Ok(())
1041 } else {
1042 shutdown_writer(writer_task, SHUTDOWN_GRACE).await
1043 };
1044 loop_result?;
1045 writer_result?;
1046 if fatal {
1047 return Err(anyhow!("protocol version mismatch"));
1048 }
1049 Ok(())
1050}
1051
1052enum FrameRead {
1053 Eof,
1054 Frame(Vec<u8>),
1055 TooLarge,
1056}
1057
1058struct OutboundFrame {
1059 bytes: Vec<u8>,
1060 _byte_permit: OwnedSemaphorePermit,
1061}
1062
1063#[derive(Clone)]
1064struct OutboundSender {
1065 frames: mpsc::Sender<OutboundFrame>,
1066 budget: Arc<Semaphore>,
1067 capacity: Arc<AtomicUsize>,
1068}
1069
1070#[derive(Debug, PartialEq, Eq)]
1071enum OutboundSendError {
1072 Cancelled,
1073 Closed,
1074 TimedOut,
1075 FrameExceedsQueue { frame_bytes: usize, capacity: usize },
1076}
1077
1078impl std::fmt::Display for OutboundSendError {
1079 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1080 match self {
1081 Self::Cancelled => formatter.write_str("outbound send cancelled"),
1082 Self::Closed => formatter.write_str("protocol client disconnected"),
1083 Self::TimedOut => formatter.write_str("outbound send timed out under backpressure"),
1084 Self::FrameExceedsQueue {
1085 frame_bytes,
1086 capacity,
1087 } => write!(
1088 formatter,
1089 "outbound frame uses {frame_bytes} bytes but queue capacity is {capacity} bytes"
1090 ),
1091 }
1092 }
1093}
1094
1095impl std::error::Error for OutboundSendError {}
1096
1097fn outbound_channel(capacity: usize) -> (OutboundSender, mpsc::Receiver<OutboundFrame>) {
1098 let (frames, receiver) = mpsc::channel(OUTPUT_QUEUE_CAPACITY);
1099 (
1100 OutboundSender {
1101 frames,
1102 budget: Arc::new(Semaphore::new(capacity)),
1103 capacity: Arc::new(AtomicUsize::new(capacity)),
1104 },
1105 receiver,
1106 )
1107}
1108
1109impl OutboundSender {
1110 fn ensure_capacity(&self, required: usize) -> Result<()> {
1111 if required > Semaphore::MAX_PERMITS {
1112 return Err(anyhow!(
1113 "outbound queue capacity {required} exceeds runtime limit {}",
1114 Semaphore::MAX_PERMITS
1115 ));
1116 }
1117 let current = self.capacity.load(Ordering::Acquire);
1118 if required > current {
1119 self.budget.add_permits(required - current);
1120 self.capacity.store(required, Ordering::Release);
1121 }
1122 Ok(())
1123 }
1124
1125 async fn send(
1126 &self,
1127 bytes: Vec<u8>,
1128 cancellation: Option<&CancellationToken>,
1129 ) -> std::result::Result<(), OutboundSendError> {
1130 self.send_with_timeout(bytes, cancellation, SHUTDOWN_GRACE)
1131 .await
1132 }
1133
1134 async fn send_with_timeout(
1135 &self,
1136 bytes: Vec<u8>,
1137 cancellation: Option<&CancellationToken>,
1138 control_timeout: Duration,
1139 ) -> std::result::Result<(), OutboundSendError> {
1140 let frame_bytes =
1141 bytes
1142 .len()
1143 .checked_add(1)
1144 .ok_or(OutboundSendError::FrameExceedsQueue {
1145 frame_bytes: usize::MAX,
1146 capacity: self.capacity.load(Ordering::Acquire),
1147 })?;
1148 let capacity = self.capacity.load(Ordering::Acquire);
1149 let permits =
1150 u32::try_from(frame_bytes).map_err(|_| OutboundSendError::FrameExceedsQueue {
1151 frame_bytes,
1152 capacity,
1153 })?;
1154 if frame_bytes > capacity {
1155 return Err(OutboundSendError::FrameExceedsQueue {
1156 frame_bytes,
1157 capacity,
1158 });
1159 }
1160
1161 let control_deadline = tokio::time::Instant::now() + control_timeout;
1162 let acquire = Arc::clone(&self.budget).acquire_many_owned(permits);
1163 let permit = if let Some(cancellation) = cancellation {
1164 tokio::select! {
1165 biased;
1166 _ = cancellation.cancelled() => return Err(OutboundSendError::Cancelled),
1167 permit = acquire => permit.map_err(|_| OutboundSendError::Closed)?,
1168 }
1169 } else {
1170 tokio::time::timeout_at(control_deadline, acquire)
1171 .await
1172 .map_err(|_| OutboundSendError::TimedOut)?
1173 .map_err(|_| OutboundSendError::Closed)?
1174 };
1175 let frame = OutboundFrame {
1176 bytes,
1177 _byte_permit: permit,
1178 };
1179 if let Some(cancellation) = cancellation {
1180 tokio::select! {
1181 biased;
1182 _ = cancellation.cancelled() => Err(OutboundSendError::Cancelled),
1183 result = self.frames.send(frame) => result.map_err(|_| OutboundSendError::Closed),
1184 }
1185 } else {
1186 tokio::time::timeout_at(control_deadline, self.frames.send(frame))
1187 .await
1188 .map_err(|_| OutboundSendError::TimedOut)?
1189 .map_err(|_| OutboundSendError::Closed)
1190 }
1191 }
1192}
1193
1194fn output_queue_bytes(max_frame_bytes: usize) -> Result<usize> {
1195 let required = max_frame_bytes
1196 .checked_add(1)
1197 .and_then(|bytes| bytes.checked_mul(2))
1198 .ok_or_else(|| anyhow!("configured server frame limit is too large"))?
1199 .max(OUTPUT_QUEUE_MIN_BYTES);
1200 if required > Semaphore::MAX_PERMITS {
1201 return Err(anyhow!(
1202 "configured server frame limit requires an outbound queue larger than the runtime supports"
1203 ));
1204 }
1205 Ok(required)
1206}
1207
1208#[derive(Default)]
1210struct FrameBuffer {
1211 bytes: Vec<u8>,
1212 oversized: bool,
1213}
1214
1215impl FrameBuffer {
1216 async fn read<R>(&mut self, reader: &mut R, max_bytes: usize) -> std::io::Result<FrameRead>
1217 where
1218 R: AsyncBufRead + Unpin,
1219 {
1220 loop {
1221 let available = reader.fill_buf().await?;
1222 let eof = available.is_empty();
1223 let end = available.iter().position(|b| *b == b'\n');
1224 let take = end.map_or(available.len(), |n| n + 1);
1225 if !self.oversized {
1226 if self.bytes.len().saturating_add(take) > max_bytes.saturating_add(2) {
1227 self.oversized = true;
1228 self.bytes.clear();
1229 } else {
1230 self.bytes.extend_from_slice(&available[..take]);
1231 }
1232 }
1233 reader.consume(take);
1234 if end.is_some() || eof {
1235 if std::mem::take(&mut self.oversized) {
1236 return Ok(FrameRead::TooLarge);
1237 }
1238 if eof && self.bytes.is_empty() {
1239 return Ok(FrameRead::Eof);
1240 }
1241 let mut bytes = std::mem::take(&mut self.bytes);
1242 while matches!(bytes.last(), Some(b'\n' | b'\r')) {
1243 bytes.pop();
1244 }
1245 return Ok(if bytes.len() > max_bytes {
1246 FrameRead::TooLarge
1247 } else {
1248 FrameRead::Frame(bytes)
1249 });
1250 }
1251 }
1252 }
1253}
1254
1255#[cfg(test)]
1256async fn read_bounded_frame<R: AsyncBufRead + Unpin>(
1257 reader: &mut R,
1258 max_bytes: usize,
1259) -> std::io::Result<FrameRead> {
1260 FrameBuffer::default().read(reader, max_bytes).await
1261}
1262
1263struct SocketLock(std::fs::File);
1265impl SocketLock {
1266 fn acquire(socket: &Path) -> Result<Self> {
1267 use std::os::unix::{fs::OpenOptionsExt, io::AsRawFd};
1268 let file = std::fs::OpenOptions::new()
1269 .read(true)
1270 .write(true)
1271 .create(true)
1272 .truncate(false)
1273 .mode(0o600)
1274 .custom_flags(libc::O_NOFOLLOW)
1275 .open(socket.with_extension("lock"))?;
1276 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
1278 return Err(anyhow!("SCV daemon already owns this socket"));
1279 }
1280 Ok(Self(file))
1281 }
1282}
1283impl Drop for SocketLock {
1284 fn drop(&mut self) {
1285 use std::os::unix::io::AsRawFd;
1286 unsafe {
1288 libc::flock(self.0.as_raw_fd(), libc::LOCK_UN);
1289 }
1290 }
1291}
1292
1293fn server_frame_limit(session: &Option<Session>) -> usize {
1294 session.as_ref().map_or_else(
1295 || Config::default().protocol.max_server_frame_bytes,
1296 |value| value.config.protocol.max_server_frame_bytes,
1297 )
1298}
1299
1300struct Session {
1301 id: String,
1302 workspace: PathBuf,
1303 config: Config,
1304 runtime: Arc<AgentRuntime>,
1305 history: Arc<Mutex<Vec<Message>>>,
1306 seq: Arc<AtomicU64>,
1307 queue: Arc<Mutex<VecDeque<QueueEntry>>>,
1308 paused: Arc<std::sync::atomic::AtomicBool>,
1309 background: Option<Arc<BackgroundJobs>>,
1311 tools: bool,
1313}
1314
1315impl Session {
1316 fn turn_input(&self, prompt: &str, attachments: &[Attachment]) -> TurnInput {
1318 attachments::turn_input(prompt, attachments, self.tools)
1319 }
1320
1321 async fn enqueue(
1322 &self,
1323 prompt: String,
1324 submitter: String,
1325 attachments: Vec<Attachment>,
1326 ) -> std::result::Result<QueueEntry, &'static str> {
1327 let entry = QueueEntry {
1328 queue_id: Uuid::new_v4().to_string(),
1329 revision: 1,
1330 prompt,
1331 submitter,
1332 attachments,
1333 };
1334 let mut queue = self.queue.lock().await;
1335 let bytes: usize = queue.iter().map(|item| item.prompt.len()).sum();
1336 if queue.len() >= MAX_QUEUE_ITEMS
1337 || bytes.saturating_add(entry.prompt.len()) > MAX_QUEUE_BYTES
1338 {
1339 return Err("queue_limit");
1340 }
1341 queue.push_back(entry.clone());
1342 Ok(entry)
1343 }
1344
1345 async fn update_queue(
1346 &self,
1347 id: &str,
1348 revision: u64,
1349 prompt: String,
1350 ) -> std::result::Result<QueueEntry, &'static str> {
1351 let mut queue = self.queue.lock().await;
1352 let bytes: usize = queue.iter().map(|item| item.prompt.len()).sum();
1353 let entry = queue
1354 .iter_mut()
1355 .find(|entry| entry.queue_id == id)
1356 .ok_or("queue_not_found")?;
1357 if entry.revision != revision {
1358 return Err("queue_conflict");
1359 }
1360 if bytes
1361 .saturating_sub(entry.prompt.len())
1362 .saturating_add(prompt.len())
1363 > MAX_QUEUE_BYTES
1364 {
1365 return Err("queue_limit");
1366 }
1367 entry.prompt = prompt;
1368 entry.revision += 1;
1369 Ok(entry.clone())
1370 }
1371
1372 async fn move_queue(
1373 &self,
1374 session_id: &str,
1375 id: &str,
1376 revision: u64,
1377 before: Option<String>,
1378 ) -> std::result::Result<(String, u64, usize), &'static str> {
1379 if self.id != session_id {
1380 return Err("session_not_found");
1381 }
1382 let mut queue = self.queue.lock().await;
1383 let index = queue
1384 .iter()
1385 .position(|entry| entry.queue_id == id)
1386 .ok_or("queue_not_found")?;
1387 if queue[index].revision != revision {
1388 return Err("queue_conflict");
1389 }
1390 let target_index = match before.as_deref() {
1393 Some(target) if target == id => return Ok((id.to_string(), revision, index)),
1394 Some(target) => Some(
1395 queue
1396 .iter()
1397 .position(|item| item.queue_id == target)
1398 .ok_or("queue_not_found")?,
1399 ),
1400 None => None,
1401 };
1402 let mut entry = queue.remove(index).expect("queue index exists");
1403 let target = target_index.map_or(queue.len(), |target| {
1404 target.saturating_sub(usize::from(target > index))
1405 });
1406 let pos = target.min(queue.len());
1407 let id = entry.queue_id.clone();
1408 let rev = entry.revision + 1;
1409 entry.revision = rev;
1410 queue.insert(pos, entry);
1411 Ok((id, rev, pos))
1412 }
1413
1414 async fn remove_queue(
1415 &self,
1416 session_id: &str,
1417 id: &str,
1418 revision: u64,
1419 ) -> std::result::Result<(String, u64), &'static str> {
1420 if self.id != session_id {
1421 return Err("session_not_found");
1422 }
1423 let mut queue = self.queue.lock().await;
1424 let index = queue
1425 .iter()
1426 .position(|entry| entry.queue_id == id)
1427 .ok_or("queue_not_found")?;
1428 if queue[index].revision != revision {
1429 return Err("queue_conflict");
1430 }
1431 let entry = queue.remove(index).expect("queue index exists");
1432 Ok((entry.queue_id, entry.revision))
1433 }
1434}
1435
1436struct ActiveTurn {
1437 turn_id: String,
1438 cancellation: CancellationToken,
1439 task: JoinHandle<()>,
1440}
1441
1442impl Drop for ActiveTurn {
1443 fn drop(&mut self) {
1444 self.cancellation.cancel();
1445 self.task.abort();
1446 }
1447}
1448
1449struct AbortGuard(tokio::task::AbortHandle);
1450impl Drop for AbortGuard {
1451 fn drop(&mut self) {
1452 self.0.abort();
1453 }
1454}
1455
1456async fn shutdown_active_turn(mut active: ActiveTurn, grace: Duration) -> bool {
1457 active.cancellation.cancel();
1458 if tokio::time::timeout(grace, &mut active.task).await.is_ok() {
1459 true
1460 } else {
1461 active.task.abort();
1462 let _ = (&mut active.task).await;
1463 false
1464 }
1465}
1466
1467async fn shutdown_writer(
1468 mut writer: JoinHandle<std::io::Result<()>>,
1469 grace: Duration,
1470) -> Result<()> {
1471 match tokio::time::timeout(grace, &mut writer).await {
1472 Ok(result) => {
1473 result.context("join protocol writer")??;
1474 Ok(())
1475 }
1476 Err(_) => {
1477 writer.abort();
1478 let _ = writer.await;
1479 Err(anyhow!("protocol writer shutdown timed out"))
1480 }
1481 }
1482}
1483
1484struct TurnDone {
1485 request_id: String,
1486 session_id: String,
1487 turn_id: String,
1488 origin: Option<TurnOrigin>,
1489 result: Result<scv_core::TurnOutcome, AgentError>,
1490}
1491
1492struct TurnStarter<'a> {
1494 output: &'a OutboundSender,
1495 approvals: &'a Arc<ApprovalBroker>,
1496 done: &'a mpsc::Sender<TurnDone>,
1497 tasks: &'a TaskTracker,
1498 cancellation: &'a CancellationToken,
1499}
1500
1501impl TurnStarter<'_> {
1502 async fn start(
1504 &self,
1505 current: &Session,
1506 turn_id: String,
1507 request_id: String,
1508 prompt: TurnInput,
1509 origin: Option<TurnOrigin>,
1510 ) -> Result<ActiveTurn> {
1511 let cancellation = self.cancellation.child_token();
1512 send_event(
1513 self.output,
1514 ServerEvent::TurnStarted {
1515 request_id: request_id.clone(),
1516 session_id: current.id.clone(),
1517 turn_id: turn_id.clone(),
1518 seq: next_seq(¤t.seq),
1519 origin: origin.clone(),
1520 },
1521 current.config.protocol.max_server_frame_bytes,
1522 )
1523 .await?;
1524 let meta = TurnMeta {
1525 request_id: request_id.clone(),
1526 session_id: current.id.clone(),
1527 turn_id: turn_id.clone(),
1528 seq: Arc::clone(¤t.seq),
1529 max_server_frame: current.config.protocol.max_server_frame_bytes,
1530 };
1531 let sink: Arc<dyn EventSink> = Arc::new(ProtocolSink {
1532 meta: meta.clone(),
1533 output: self.output.clone(),
1534 cancellation: cancellation.clone(),
1535 });
1536 let gate: Arc<dyn ApprovalGate> = Arc::new(ProtocolApprovalGate {
1537 policy: current.config.tools.approval_policy,
1538 broker: Arc::clone(self.approvals),
1539 meta,
1540 output: self.output.clone(),
1541 });
1542 let runtime = Arc::clone(¤t.runtime);
1543 let history = Arc::clone(¤t.history);
1544 let done = self.done.clone();
1545 let session_id = current.id.clone();
1546 let task_turn = turn_id.clone();
1547 let task_cancel = cancellation.clone();
1548 let task = self.tasks.spawn(async move {
1549 let mut history = history.lock().await;
1550 let result = runtime
1551 .run_turn(&mut history, prompt, sink, gate, task_cancel)
1552 .await;
1553 let _ = done
1554 .send(TurnDone {
1555 request_id,
1556 session_id,
1557 turn_id: task_turn,
1558 origin,
1559 result,
1560 })
1561 .await;
1562 });
1563 Ok(ActiveTurn {
1564 turn_id,
1565 cancellation,
1566 task,
1567 })
1568 }
1569
1570 async fn report_background(&self, current: &Session) -> Result<Option<ActiveTurn>> {
1573 let Some(jobs) = ¤t.background else {
1574 return Ok(None);
1575 };
1576 let reports = jobs.take_unreported();
1577 if reports.is_empty() {
1578 return Ok(None);
1579 }
1580 let origin = TurnOrigin {
1581 kind: ORIGIN_BACKGROUND.into(),
1582 jobs: reports.iter().map(|report| report.job.clone()).collect(),
1583 };
1584 let prompt = background::report_prompt(&reports);
1585 let request_id = format!("background:{}", Uuid::new_v4());
1586 self.start(
1587 current,
1588 Uuid::new_v4().to_string(),
1589 request_id,
1590 prompt.into(),
1591 Some(origin),
1592 )
1593 .await
1594 .map(Some)
1595 }
1596}
1597
1598async fn recv_background(receiver: &mut Option<mpsc::UnboundedReceiver<()>>) -> Option<()> {
1600 match receiver {
1601 Some(receiver) => receiver.recv().await,
1602 None => std::future::pending().await,
1603 }
1604}
1605
1606#[derive(Clone)]
1607struct TurnMeta {
1608 request_id: String,
1609 session_id: String,
1610 turn_id: String,
1611 seq: Arc<AtomicU64>,
1612 max_server_frame: usize,
1613}
1614
1615fn next_seq(sequence: &AtomicU64) -> u64 {
1616 sequence.fetch_add(1, Ordering::Relaxed) + 1
1617}
1618
1619#[derive(Debug, Default)]
1621struct SessionClient {
1622 channel: Option<String>,
1624 auto_approve: bool,
1626}
1627
1628fn valid_channel_name(name: &str) -> bool {
1629 !name.trim().is_empty()
1630 && name.len() <= scv_protocol::MAX_CHANNEL_NAME_BYTES
1631 && !name.chars().any(char::is_control)
1632}
1633
1634fn offered_adapters(config: &Config) -> HashMap<String, scv_tools::AgentAdapterConfig> {
1638 let mut adapters = config.adapters();
1639 adapters.retain(|tool, adapter| {
1640 let descriptor = tool
1641 .strip_prefix("agent_")
1642 .and_then(scv_tools::adapters::adapter);
1643 match (
1644 descriptor.map(|descriptor| descriptor.status),
1645 &adapter.home,
1646 ) {
1647 (Some(scv_tools::adapters::Status::Stored(store)), Some(home)) => {
1648 !matches!(agents::stored_status(store, home), Ok((false, _)))
1649 }
1650 _ => true,
1651 }
1652 });
1653 adapters
1654}
1655
1656fn agent_tool_names(tools: &ToolRegistry) -> Vec<String> {
1658 let mut names: Vec<String> = tools
1659 .specs()
1660 .into_iter()
1661 .map(|spec| spec.name)
1662 .filter(|name| {
1663 name.starts_with("agent_")
1664 && !["agent_wait", "agent_status", "agent_cancel"].contains(&name.as_str())
1665 })
1666 .collect();
1667 names.sort();
1668 names
1669}
1670
1671async fn build_session(
1677 cwd: &str,
1678 overrides: ConfigOverrides,
1679 delegation_depth: u32,
1680 registry: &Arc<DelegationRegistry>,
1681 client: SessionClient,
1682) -> Result<(Session, Option<mpsc::UnboundedReceiver<()>>)> {
1683 let id = Uuid::new_v4().to_string();
1684 let workspace = std::fs::canonicalize(cwd).with_context(|| format!("resolve cwd {cwd}"))?;
1685 if !workspace.is_dir() {
1686 return Err(anyhow!("cwd is not a directory"));
1687 }
1688 let no_tools = overrides.no_tools;
1689 let config = Config::load(&workspace, overrides)?;
1690 if !no_tools {
1691 config.prepare_adapter_homes()?;
1692 }
1693 let provider_config = config.provider.clone();
1694 let api_key = provider_config.api_key.clone().or_else(|| {
1695 provider_config.api_key_env.as_deref().and_then(|name| std::env::var(name).ok())
1696 }).filter(|key| !key.trim().is_empty()).ok_or_else(|| anyhow!("provider credential is not configured; set provider.api_key or provider.api_key_env"))?;
1697 let skills = discover_skills(&workspace, &config, !no_tools)?;
1698 let listings = SkillListings {
1699 listing: skills.listing,
1700 project_listing: skills.project_listing,
1701 };
1702 let mut provider = OpenAiProvider::new(
1703 provider_config.model.clone(),
1704 provider_config.base_url.clone(),
1705 api_key,
1706 Duration::from_secs(provider_config.timeout_seconds),
1707 config.provider_limits(),
1708 provider_config.headers.clone(),
1709 )?
1710 .with_image_input(provider_config.image_input);
1711 if !no_tools && config.hosted_web_search() {
1712 provider = provider.with_web_search();
1713 }
1714 let provider = Arc::new(provider);
1715 let (background, finished) = if !no_tools && config.agent.max_background > 0 {
1716 let (finished_tx, finished_rx) = mpsc::unbounded_channel();
1717 let unattended: Arc<dyn ApprovalGate> = Arc::new(UnattendedGate {
1718 policy: config.tools.approval_policy,
1719 client_approves_all: client.auto_approve,
1720 });
1721 (
1722 Some(Arc::new(
1723 BackgroundJobs::new(config.agent.max_background, Some(finished_tx))
1724 .with_approvals(unattended),
1725 )),
1726 Some(finished_rx),
1727 )
1728 } else {
1729 (None, None)
1730 };
1731 let tools = if no_tools {
1732 Arc::new(ToolRegistry::default())
1733 } else {
1734 let mut tools = config.tools();
1735 tools.delegation = Some(DelegationContext {
1736 registry: Arc::clone(registry),
1737 session: id.clone(),
1738 depth: delegation_depth,
1739 });
1740 tools.background = background.clone();
1741 if client.channel.is_some() {
1742 tools.chat_attach = chat_attach_config();
1743 }
1744 let mut registry = builtin_registry(
1745 tools,
1746 skills.map,
1747 skills.roots,
1748 config.skills.max_skill_bytes,
1749 offered_adapters(&config),
1750 )?;
1751 if let Some(web) = config.web_tools() {
1752 scv_tools::web::register(&mut registry, web)?;
1753 }
1754 Arc::new(registry)
1755 };
1756 let agents = agent_tool_names(&tools);
1757 let system_prompt = build_system_prompt(
1758 &workspace,
1759 &config,
1760 &listings,
1761 &PromptContext {
1762 agents: &agents,
1763 background: tools.get("agent_status").is_some(),
1764 channel: client.channel.as_deref(),
1765 },
1766 )?;
1767 let context = Arc::new(BudgetContextPolicy::new((&config.context).into())?);
1768 let runtime = Arc::new(AgentRuntime::new(
1769 provider,
1770 tools,
1771 context,
1772 config.core_agent(system_prompt),
1773 workspace.clone(),
1774 ));
1775 Ok((
1776 Session {
1777 id,
1778 workspace,
1779 config,
1780 runtime,
1781 history: Arc::new(Mutex::new(Vec::new())),
1782 seq: Arc::new(AtomicU64::new(0)),
1783 queue: Arc::new(Mutex::new(VecDeque::new())),
1784 paused: Arc::new(std::sync::atomic::AtomicBool::new(false)),
1785 background,
1786 tools: !no_tools,
1787 },
1788 finished,
1789 ))
1790}
1791
1792fn chat_attach_config() -> Option<scv_tools::chat_attach::ChatAttachConfig> {
1796 let scv_home = config::user_home_path()?;
1797 let media = scv_client::Layout::new(&scv_home).media();
1798 Some(scv_tools::chat_attach::ChatAttachConfig::standard(
1799 dirs::home_dir().as_deref(),
1800 &scv_home,
1801 scv_channels::media::outbox(&media),
1802 vec![media],
1803 scv_channels::media::MAX_REPLY_FILE_BYTES,
1804 ))
1805}
1806
1807struct SkillListings {
1809 listing: String,
1810 project_listing: String,
1811}
1812
1813struct PromptContext<'a> {
1815 agents: &'a [String],
1817 background: bool,
1819 channel: Option<&'a str>,
1821}
1822
1823fn build_system_prompt(
1824 workspace: &Path,
1825 config: &Config,
1826 skills: &SkillListings,
1827 context: &PromptContext<'_>,
1828) -> Result<String> {
1829 let mut prompt = config.agent.system_prompt.clone();
1830 prompt.push_str(&format!(
1831 "\nCurrent working directory: {}\n",
1832 workspace.display()
1833 ));
1834 let agents_path = workspace.join("AGENTS.md");
1835 if agents_path.is_file() {
1836 let canonical = std::fs::canonicalize(&agents_path).context("resolve project AGENTS.md")?;
1837 if !canonical.starts_with(workspace) {
1838 return Err(anyhow!("project AGENTS.md escaped workspace"));
1839 }
1840 let (bytes, truncated) = read_prefix(&canonical, config.tools.max_read_bytes)
1841 .context("read project AGENTS.md")?;
1842 let instructions = std::str::from_utf8(&bytes).context("project AGENTS.md is not UTF-8")?;
1843 prompt.push_str("\n# Project instructions\n");
1844 prompt.push_str(instructions);
1845 if truncated {
1846 prompt.push_str("\n[AGENTS.md truncated by configured read limit]\n");
1847 }
1848 }
1849 if !skills.listing.is_empty() {
1850 prompt.push_str("\n# Available skills\n");
1851 prompt.push_str(&skills.listing);
1852 prompt.push_str("\nUse read_skill with a skill name when its workflow applies.\n");
1853 }
1854 if !skills.project_listing.is_empty() {
1855 prompt.push_str("\n# Project skills\n");
1856 prompt.push_str(
1857 "Projects in this workspace provide these skills to agents working in them:\n",
1858 );
1859 prompt.push_str(&skills.project_listing);
1860 match context.agents {
1861 [] => prompt.push_str("\nread_skill loads one for reference.\n"),
1862 agents => prompt.push_str(&format!(
1863 "\nTo use one, delegate with an agent tool such as {}, set its cwd to the \
1864 skill's project, and name the skill in the prompt: that agent then loads the \
1865 project's instructions and skills itself. read_skill loads a skill for \
1866 reference.\n",
1867 agents
1868 .iter()
1869 .take(2)
1870 .map(String::as_str)
1871 .collect::<Vec<_>>()
1872 .join(" or ")
1873 )),
1874 }
1875 }
1876 if !context.agents.is_empty() {
1877 prompt.push_str(&delegation_guidance(config, context));
1878 }
1879 if let Some(channel) = context.channel {
1880 prompt.push_str(&format!(
1881 "\n# Chat channel\n\
1882 This conversation takes place on {channel}. The user reads your replies there as \
1883 chat messages, so keep them short and in plain text, without tables, headings, \
1884 or code blocks unless the user asks for them. Only the last message of each turn \
1885 reaches the user, and they never see your tool calls or their output, so put what \
1886 you did and what you found into that message in words.\n"
1887 ));
1888 }
1889 Ok(prompt)
1890}
1891
1892fn delegation_guidance(config: &Config, context: &PromptContext<'_>) -> String {
1895 let named: Vec<String> = context
1896 .agents
1897 .iter()
1898 .map(|tool| format!("{tool} ({})", scv_tools::agent_choice::product(tool)))
1899 .collect();
1900 let mut text = format!(
1901 "\n# Delegating work\n\
1902 You can hand work to these agents: {}. Each tool's description says what that agent \
1903 offers.",
1904 named.join(", ")
1905 );
1906 let preferred: Vec<String> = config
1907 .agent
1908 .prefer
1909 .iter()
1910 .map(|agent| format!("agent_{agent}"))
1911 .filter(|tool| context.agents.contains(tool))
1912 .collect();
1913 if !preferred.is_empty() {
1914 text.push_str(&format!(
1915 " The user prefers {}, in that order; choose another when the work needs \
1916 something only it offers, or when a preferred one is unavailable.",
1917 preferred.join(", ")
1918 ));
1919 }
1920 if context.background {
1921 text.push_str(
1922 "\n\nStay available to the user: while one of your turns runs, they cannot reach \
1923 you. Handle quick things yourself, such as short reads, lookups, status checks, \
1924 and answers you can give in a step or two. Hand real work to an agent with \
1925 background set to true: changes to code or files, multi-step investigation, \
1926 builds, tests, releases, and anything else likely to take more than about a \
1927 minute. Then reply right away with what you started and its job handle.\n\n\
1928 The agent does not see this conversation, so write a brief that stands on its \
1929 own: the goal, the project directory (cwd), what you already know, constraints, \
1930 and what to report back.\n\n\
1931 When a job finishes, SCV starts a turn with an [SCV background report]; tell the \
1932 user what happened and the key result. agent_status shows how jobs are going, \
1933 and agent_cancel stops one the user no longer wants. agent_wait, foreground \
1934 agent calls, and long bash commands keep the user waiting, so use them only for \
1935 results you need within this turn that arrive quickly.\n",
1936 );
1937 } else {
1938 text.push_str(
1939 "\n\nHand substantial work to an agent rather than doing it step by step with \
1940 bash. The agent does not see this conversation, so write a brief that stands on \
1941 its own: the goal, the project directory (cwd), what you already know, \
1942 constraints, and what to report back.\n",
1943 );
1944 }
1945 text.push_str(
1948 "\nIf an agent declines a request, tell the user what it said; don't pass the request \
1949 to another agent on your own. If the user then asks for a specific agent, use it.\n",
1950 );
1951 text
1952}
1953
1954struct DiscoveredSkills {
1957 map: SkillMap,
1958 roots: Vec<PathBuf>,
1959 listing: String,
1960 project_listing: String,
1961}
1962
1963const PROJECT_SKILL_DIRS: [&str; 2] = [".agents/skills", ".claude/skills"];
1966const MAX_WORKSPACE_ENTRIES: usize = 4096;
1969const MAX_SKILL_PROJECTS: usize = 256;
1970const PROJECT_SKILL_HEADER_BYTES: usize = 16 * 1024;
1972const MAX_PROJECT_SKILL_DESCRIPTION: usize = 400;
1973
1974fn discover_skills(workspace: &Path, config: &Config, tools: bool) -> Result<DiscoveredSkills> {
1975 let mut skills = SkillMap::new();
1976 let mut roots = Vec::new();
1977 let project_root = workspace.join(&config.skills.project_dir);
1978 for (root, must_be_workspace) in [(&project_root, true), (&config.skills.user_dir, false)] {
1979 if !root.is_dir() {
1980 continue;
1981 }
1982 let canonical = std::fs::canonicalize(root)
1983 .with_context(|| format!("resolve skill root {}", root.display()))?;
1984 if must_be_workspace && !canonical.starts_with(workspace) {
1985 return Err(anyhow!("project skill root escaped workspace"));
1986 }
1987 roots.push(canonical.clone());
1988 let mut entries: Vec<_> = std::fs::read_dir(&canonical)
1989 .with_context(|| format!("read skill root {}", canonical.display()))?
1990 .filter_map(Result::ok)
1991 .collect();
1992 entries.sort_by_key(|entry| entry.file_name());
1993 for entry in entries {
1994 if skills.len() >= config.skills.max_skills {
1995 break;
1996 }
1997 let path = entry.path().join("SKILL.md");
1998 if !path.is_file() {
1999 continue;
2000 }
2001 let canonical_file = std::fs::canonicalize(&path)
2002 .with_context(|| format!("resolve skill {}", path.display()))?;
2003 if !canonical_file.starts_with(&canonical) {
2004 continue;
2005 }
2006 let name = entry.file_name().to_string_lossy().to_string();
2007 skills.entry(name).or_insert(canonical_file);
2008 }
2009 }
2010 let mut names: Vec<_> = skills.keys().cloned().collect();
2011 names.sort();
2012 let mut listing = String::new();
2013 for name in names {
2014 let path = &skills[&name];
2015 let bytes = read_prefix(path, config.skills.max_skill_bytes)
2016 .map(|(bytes, _)| bytes)
2017 .unwrap_or_default();
2018 let content = String::from_utf8_lossy(&bytes);
2019 let description = skill_description(&content);
2020 listing.push_str(&format!("- {name}: {description}\n"));
2021 }
2022 let project_listing = if tools && config.skills.scan_projects {
2025 discover_project_skills(workspace, config, &mut skills, &mut roots)
2026 } else {
2027 String::new()
2028 };
2029 Ok(DiscoveredSkills {
2030 map: skills,
2031 roots,
2032 listing,
2033 project_listing,
2034 })
2035}
2036
2037fn discover_project_skills(
2043 workspace: &Path,
2044 config: &Config,
2045 skills: &mut SkillMap,
2046 roots: &mut Vec<PathBuf>,
2047) -> String {
2048 let mut projects = vec![(None, workspace.to_path_buf())];
2049 let mut names: Vec<_> = std::fs::read_dir(workspace)
2050 .into_iter()
2051 .flatten()
2052 .filter_map(Result::ok)
2053 .take(MAX_WORKSPACE_ENTRIES)
2054 .map(|entry| entry.file_name().to_string_lossy().into_owned())
2055 .filter(|name| !name.starts_with('.'))
2056 .collect();
2057 names.sort();
2058 for name in names {
2059 if projects.len() > MAX_SKILL_PROJECTS {
2060 break;
2061 }
2062 let Ok(directory) = std::fs::canonicalize(workspace.join(&name)) else {
2063 continue;
2064 };
2065 if directory.is_dir()
2068 && directory.starts_with(workspace)
2069 && !directory.join(".git").is_file()
2070 && !projects.iter().any(|(_, seen)| seen == &directory)
2071 {
2072 projects.push((Some(name), directory));
2073 }
2074 }
2075 let mut seen_files = std::collections::HashSet::new();
2076 let mut listing = String::new();
2077 'projects: for (project, directory) in projects {
2078 let project_roots: Vec<PathBuf> = PROJECT_SKILL_DIRS
2079 .iter()
2080 .filter_map(|relative| std::fs::canonicalize(directory.join(relative)).ok())
2081 .filter(|root| root.is_dir() && root.starts_with(workspace))
2082 .collect();
2083 for root in &project_roots {
2084 if !roots.contains(root) {
2085 roots.push(root.clone());
2086 }
2087 }
2088 for root in &project_roots {
2089 let mut entries: Vec<_> = std::fs::read_dir(root)
2090 .into_iter()
2091 .flatten()
2092 .filter_map(Result::ok)
2093 .take(MAX_WORKSPACE_ENTRIES)
2094 .collect();
2095 entries.sort_by_key(|entry| entry.file_name());
2096 for entry in entries {
2097 if skills.len() >= config.skills.max_skills {
2098 break 'projects;
2099 }
2100 let Ok(file) = std::fs::canonicalize(entry.path().join("SKILL.md")) else {
2101 continue;
2102 };
2103 if !file.is_file()
2104 || !project_roots.iter().any(|root| file.starts_with(root))
2105 || !seen_files.insert(file.clone())
2106 {
2107 continue;
2108 }
2109 let skill = entry.file_name().to_string_lossy().into_owned();
2110 let (name, location) = match &project {
2111 Some(project) => (format!("{project}:{skill}"), format!("project {project}")),
2112 None => (skill, "workspace root".to_owned()),
2113 };
2114 if skills.contains_key(&name) {
2116 continue;
2117 }
2118 let header = read_prefix(
2119 &file,
2120 config
2121 .skills
2122 .max_skill_bytes
2123 .min(PROJECT_SKILL_HEADER_BYTES),
2124 )
2125 .map(|(bytes, _)| bytes)
2126 .unwrap_or_default();
2127 let description: String = skill_description(&String::from_utf8_lossy(&header))
2128 .chars()
2129 .take(MAX_PROJECT_SKILL_DESCRIPTION)
2130 .collect();
2131 listing.push_str(&format!("- {name} ({location}): {description}\n"));
2132 skills.insert(name, file);
2133 }
2134 }
2135 }
2136 listing
2137}
2138
2139fn read_prefix(path: &Path, max_bytes: usize) -> std::io::Result<(Vec<u8>, bool)> {
2140 let file = std::fs::File::open(path)?;
2141 let mut bytes = Vec::with_capacity(max_bytes.min(8192));
2142 file.take(
2143 u64::try_from(max_bytes)
2144 .unwrap_or(u64::MAX)
2145 .saturating_add(1),
2146 )
2147 .read_to_end(&mut bytes)?;
2148 let truncated = bytes.len() > max_bytes;
2149 bytes.truncate(max_bytes);
2150 Ok((bytes, truncated))
2151}
2152
2153fn skill_description(content: &str) -> String {
2154 if let Some(frontmatter) = content.strip_prefix("---\n")
2155 && let Some((header, _)) = frontmatter.split_once("\n---")
2156 {
2157 for line in header.lines() {
2158 if let Some(description) = line.strip_prefix("description:") {
2159 return description.trim().trim_matches('"').to_owned();
2160 }
2161 }
2162 }
2163 content
2164 .lines()
2165 .map(str::trim)
2166 .find(|line| !line.is_empty() && !line.starts_with('#'))
2167 .unwrap_or("No description provided")
2168 .chars()
2169 .take(240)
2170 .collect()
2171}
2172
2173fn bounded_progress(mut text: String) -> String {
2175 let limit = scv_core::MAX_PROGRESS_EVENT_BYTES;
2176 if text.len() > limit {
2177 let mut end = limit;
2178 while !text.is_char_boundary(end) {
2179 end -= 1;
2180 }
2181 text.truncate(end);
2182 }
2183 text
2184}
2185
2186struct ProtocolSink {
2187 meta: TurnMeta,
2188 output: OutboundSender,
2189 cancellation: CancellationToken,
2190}
2191
2192#[async_trait]
2193impl EventSink for ProtocolSink {
2194 async fn emit(&self, event: CoreEvent) -> Result<(), AgentError> {
2195 let seq = next_seq(&self.meta.seq);
2196 let event = match event {
2197 CoreEvent::AssistantDelta { content } => ServerEvent::AssistantDelta {
2198 request_id: self.meta.request_id.clone(),
2199 session_id: self.meta.session_id.clone(),
2200 turn_id: self.meta.turn_id.clone(),
2201 seq,
2202 content,
2203 },
2204 CoreEvent::AssistantCompleted { content } => ServerEvent::AssistantCompleted {
2205 request_id: self.meta.request_id.clone(),
2206 session_id: self.meta.session_id.clone(),
2207 turn_id: self.meta.turn_id.clone(),
2208 seq,
2209 content,
2210 },
2211 CoreEvent::ToolProposed {
2212 call_id,
2213 name,
2214 arguments,
2215 } => ServerEvent::ToolProposed {
2216 request_id: self.meta.request_id.clone(),
2217 session_id: self.meta.session_id.clone(),
2218 turn_id: self.meta.turn_id.clone(),
2219 seq,
2220 call_id,
2221 name,
2222 arguments,
2223 },
2224 CoreEvent::ToolStarted { call_id, name } => ServerEvent::ToolStarted {
2225 request_id: self.meta.request_id.clone(),
2226 session_id: self.meta.session_id.clone(),
2227 turn_id: self.meta.turn_id.clone(),
2228 seq,
2229 call_id,
2230 name,
2231 },
2232 CoreEvent::ToolProgress { call_id, text } => ServerEvent::ToolProgress {
2233 request_id: self.meta.request_id.clone(),
2234 session_id: self.meta.session_id.clone(),
2235 turn_id: self.meta.turn_id.clone(),
2236 seq,
2237 call_id,
2238 text: bounded_progress(text),
2241 },
2242 CoreEvent::ToolCompleted {
2243 call_id,
2244 name,
2245 output,
2246 } => ServerEvent::ToolCompleted {
2247 request_id: self.meta.request_id.clone(),
2248 session_id: self.meta.session_id.clone(),
2249 turn_id: self.meta.turn_id.clone(),
2250 seq,
2251 call_id,
2252 name,
2253 success: !output.is_error,
2254 output: output.content,
2255 truncated: output.truncated,
2256 },
2257 CoreEvent::ContextCompacted {
2258 before_tokens,
2259 after_tokens,
2260 removed_messages,
2261 } => ServerEvent::ContextCompacted {
2262 request_id: self.meta.request_id.clone(),
2263 session_id: self.meta.session_id.clone(),
2264 turn_id: self.meta.turn_id.clone(),
2265 seq,
2266 before_tokens,
2267 after_tokens,
2268 removed_messages,
2269 },
2270 CoreEvent::SessionTrimmed {
2271 removed_messages,
2272 history_bytes,
2273 } => ServerEvent::SessionTrimmed {
2274 request_id: self.meta.request_id.clone(),
2275 session_id: self.meta.session_id.clone(),
2276 seq,
2277 removed_messages,
2278 history_bytes,
2279 },
2280 };
2281 send_turn_event(
2282 &self.output,
2283 event,
2284 self.meta.max_server_frame,
2285 &self.cancellation,
2286 )
2287 .await
2288 }
2289}
2290
2291#[derive(Default)]
2292struct ApprovalBroker {
2293 pending: Mutex<HashMap<String, oneshot::Sender<bool>>>,
2294}
2295
2296impl ApprovalBroker {
2297 async fn insert(&self, id: String, sender: oneshot::Sender<bool>) {
2298 self.pending.lock().await.insert(id, sender);
2299 }
2300
2301 async fn remove(&self, id: &str) {
2302 self.pending.lock().await.remove(id);
2303 }
2304
2305 async fn resolve(&self, id: &str, approved: bool) -> bool {
2306 let sender = self.pending.lock().await.remove(id);
2307 sender.is_some_and(|sender| sender.send(approved).is_ok())
2308 }
2309}
2310
2311fn policy_decision(policy: ApprovalPolicy, risk: ToolRisk) -> Option<bool> {
2314 match policy {
2315 ApprovalPolicy::OnRisk if risk == ToolRisk::ReadOnly => Some(true),
2316 ApprovalPolicy::Never => Some(risk == ToolRisk::ReadOnly),
2317 ApprovalPolicy::Always | ApprovalPolicy::OnRisk => None,
2318 }
2319}
2320
2321struct ProtocolApprovalGate {
2322 policy: ApprovalPolicy,
2323 broker: Arc<ApprovalBroker>,
2324 meta: TurnMeta,
2325 output: OutboundSender,
2326}
2327
2328struct UnattendedGate {
2334 policy: ApprovalPolicy,
2335 client_approves_all: bool,
2336}
2337
2338#[async_trait]
2339impl ApprovalGate for UnattendedGate {
2340 async fn approve(
2341 &self,
2342 request: ApprovalRequest,
2343 _cancellation: CancellationToken,
2344 ) -> Result<bool, AgentError> {
2345 Ok(policy_decision(self.policy, request.risk).unwrap_or(self.client_approves_all))
2346 }
2347}
2348
2349#[async_trait]
2350impl ApprovalGate for ProtocolApprovalGate {
2351 async fn approve(
2352 &self,
2353 request: ApprovalRequest,
2354 cancellation: CancellationToken,
2355 ) -> Result<bool, AgentError> {
2356 if let Some(decision) = policy_decision(self.policy, request.risk) {
2357 return Ok(decision);
2358 }
2359 let approval_id = Uuid::new_v4().to_string();
2360 let (sender, receiver) = oneshot::channel();
2361 self.broker.insert(approval_id.clone(), sender).await;
2362 let event = ServerEvent::ApprovalRequested {
2363 request_id: self.meta.request_id.clone(),
2364 session_id: self.meta.session_id.clone(),
2365 turn_id: self.meta.turn_id.clone(),
2366 seq: next_seq(&self.meta.seq),
2367 approval_id: approval_id.clone(),
2368 call_id: request.call_id,
2369 name: request.name,
2370 risk: request.risk.as_str().into(),
2371 cwd: request.cwd.display().to_string(),
2372 summary: request.summary,
2373 };
2374 if let Err(error) = send_turn_event(
2375 &self.output,
2376 event,
2377 self.meta.max_server_frame,
2378 &cancellation,
2379 )
2380 .await
2381 {
2382 self.broker.remove(&approval_id).await;
2383 return Err(error);
2384 }
2385 tokio::select! {
2386 result = receiver => result.map_err(|_| AgentError::Cancelled),
2387 _ = cancellation.cancelled() => {
2388 self.broker.remove(&approval_id).await;
2389 Err(AgentError::Cancelled)
2390 }
2391 }
2392 }
2393}
2394
2395async fn send_event(output: &OutboundSender, event: ServerEvent, max_bytes: usize) -> Result<()> {
2396 let bytes = encode_event(&event, max_bytes)?;
2397 output.send(bytes, None).await.map_err(anyhow::Error::new)
2398}
2399
2400async fn send_turn_event(
2401 output: &OutboundSender,
2402 event: ServerEvent,
2403 max_bytes: usize,
2404 cancellation: &CancellationToken,
2405) -> Result<(), AgentError> {
2406 let bytes = encode_event(&event, max_bytes)
2407 .map_err(|error| AgentError::ResponseLimit(error.to_string()))?;
2408 match output.send(bytes, Some(cancellation)).await {
2409 Ok(()) => Ok(()),
2410 Err(OutboundSendError::Cancelled) => Err(AgentError::Cancelled),
2411 Err(error) => Err(AgentError::Internal(error.to_string())),
2412 }
2413}
2414
2415fn encode_event(event: &ServerEvent, max_bytes: usize) -> Result<Vec<u8>> {
2416 let bytes = serde_json::to_vec(event).context("serialize protocol event")?;
2417 if bytes.len() > max_bytes {
2418 return Err(anyhow!("server event exceeds configured frame limit"));
2419 }
2420 Ok(bytes)
2421}
2422
2423async fn send_error(
2424 output: &OutboundSender,
2425 request_id: &str,
2426 code: &str,
2427 message: &str,
2428 fatal: bool,
2429 max_bytes: usize,
2430) -> Result<()> {
2431 send_event(
2432 output,
2433 ServerEvent::Error {
2434 request_id: (!request_id.is_empty()).then(|| request_id.to_owned()),
2435 code: code.into(),
2436 message: message.into(),
2437 fatal,
2438 },
2439 max_bytes,
2440 )
2441 .await
2442}
2443
2444#[cfg(test)]
2445mod tests {
2446 use std::{
2447 future::pending,
2448 io::Cursor,
2449 sync::atomic::{AtomicBool, Ordering},
2450 };
2451
2452 #[test]
2453 fn delegation_records_live_where_the_layout_says() {
2454 let home = std::path::Path::new("/tmp/scv-layout-check");
2455 let registry = DelegationRegistry::new(home);
2456 let layout = scv_client::Layout::new(home);
2457 assert_eq!(registry.record_dir(), layout.delegations());
2458 assert_eq!(registry.conversation_dir(), layout.conversations());
2459 }
2460
2461 fn test_registry() -> Arc<DelegationRegistry> {
2463 let home = tempfile::tempdir().unwrap().keep();
2464 Arc::new(DelegationRegistry::new(&home))
2465 }
2466
2467 use super::*;
2468
2469 #[test]
2470 fn clients_and_tools_agree_on_the_depth_variable() {
2471 assert_eq!(
2472 scv_client::DELEGATION_DEPTH_VARIABLE,
2473 scv_tools::delegation::DEPTH_VARIABLE
2474 );
2475 }
2476
2477 #[test]
2478 fn progress_text_is_bounded_on_a_character_boundary() {
2479 let text = "é".repeat(400);
2480 let bounded = bounded_progress(text);
2481 assert!(bounded.len() <= scv_core::MAX_PROGRESS_EVENT_BYTES);
2482 assert!(bounded.chars().all(|character| character == 'é'));
2483 assert_eq!(bounded_progress("short".into()), "short");
2484 }
2485
2486 struct DropSignal(Arc<AtomicBool>);
2487
2488 impl Drop for DropSignal {
2489 fn drop(&mut self) {
2490 self.0.store(true, Ordering::Release);
2491 }
2492 }
2493
2494 #[tokio::test]
2495 async fn nonreading_management_client_does_not_hold_component_lock() {
2496 let (mut input, server_input) = tokio::io::duplex(65536);
2497 let (server_output, _blocked_output) = tokio::io::duplex(1);
2498 let tasks = TaskTracker::new();
2499 let components = Arc::new(Mutex::new(components::Components::new(
2500 PathBuf::from("/unused.sock"),
2501 PathBuf::from("/"),
2502 )));
2503 let cancel = CancellationToken::new();
2504 let handler = tokio::spawn(run_managed(
2505 server_input,
2506 server_output,
2507 ConfigOverrides::default(),
2508 Some(components.clone()),
2509 test_registry(),
2510 cancel.clone(),
2511 tasks.clone(),
2512 ));
2513 input.write_all(b"{\"type\":\"initialize\",\"request_id\":\"init\",\"protocol_version\":2,\"client\":{\"name\":\"test\",\"version\":\"0\"}}\n").await.unwrap();
2514 for _ in 0..300 {
2515 input.write_all(b"{\"type\":\"daemon.control\",\"request_id\":\"s\",\"command\":{\"action\":\"status\"}}\n").await.unwrap();
2516 }
2517 tokio::time::sleep(Duration::from_millis(50)).await;
2518 let status = tokio::time::timeout(Duration::from_millis(100), async {
2519 components.lock().await.status()
2520 })
2521 .await
2522 .unwrap();
2523 assert_eq!(status.pid, std::process::id());
2524 cancel.cancel();
2525 handler.abort();
2526 let _ = handler.await;
2527 tasks.close();
2528 tokio::time::timeout(Duration::from_secs(1), tasks.wait())
2529 .await
2530 .unwrap();
2531 }
2532
2533 #[tokio::test]
2534 async fn forced_connection_abort_drops_and_joins_writer_descendants() {
2535 let (mut input, server_input) = tokio::io::duplex(512);
2536 let (server_output, _blocked_output) = tokio::io::duplex(1);
2537 let tasks = TaskTracker::new();
2538 let handler = tokio::spawn(run_managed(
2539 server_input,
2540 server_output,
2541 ConfigOverrides::default(),
2542 None,
2543 test_registry(),
2544 CancellationToken::new(),
2545 tasks.clone(),
2546 ));
2547 input.write_all(b"{\"type\":\"initialize\",\"request_id\":\"init\",\"protocol_version\":2,\"client\":{\"name\":\"test\",\"version\":\"0\"}}\n").await.unwrap();
2548 tokio::time::timeout(Duration::from_secs(1), async {
2549 while tasks.is_empty() {
2550 tokio::task::yield_now().await;
2551 }
2552 })
2553 .await
2554 .unwrap();
2555 handler.abort();
2556 let _ = handler.await;
2557 tasks.close();
2558 tokio::time::timeout(Duration::from_secs(1), tasks.wait())
2559 .await
2560 .unwrap();
2561 assert!(tasks.is_empty());
2562 }
2563
2564 #[tokio::test]
2565 async fn forced_handler_abort_cancels_and_joins_active_turn() {
2566 let tasks = TaskTracker::new();
2567 let cancellation = CancellationToken::new();
2568 let child_cancel = cancellation.child_token();
2569 let observed_cancel = child_cancel.clone();
2570 let dropped = Arc::new(AtomicBool::new(false));
2571 let (ready_tx, ready_rx) = oneshot::channel();
2572 let task = tasks.spawn({
2573 let dropped = dropped.clone();
2574 async move {
2575 let _guard = DropSignal(dropped);
2576 let _ = ready_tx.send(());
2577 pending::<()>().await;
2578 }
2579 });
2580 ready_rx.await.unwrap();
2581 let (owned_tx, owned_rx) = oneshot::channel();
2582 let handler = tokio::spawn(async move {
2583 let _active = ActiveTurn {
2584 turn_id: "test".into(),
2585 cancellation: child_cancel,
2586 task,
2587 };
2588 let _ = owned_tx.send(());
2589 pending::<()>().await;
2590 });
2591 owned_rx.await.unwrap();
2592 handler.abort();
2593 let _ = handler.await;
2594 tasks.close();
2595 tokio::time::timeout(Duration::from_secs(1), tasks.wait())
2596 .await
2597 .unwrap();
2598 assert!(observed_cancel.is_cancelled());
2599 assert!(dropped.load(Ordering::Acquire));
2600 }
2601
2602 #[tokio::test]
2603 async fn frame_buffer_preserves_partial_and_discard_state_across_cancellation() {
2604 let (mut input, output) = tokio::io::duplex(64);
2605 let mut reader = BufReader::new(output);
2606 let mut frames = FrameBuffer::default();
2607 input.write_all(b"12").await.unwrap();
2608 assert!(
2609 tokio::time::timeout(Duration::from_millis(10), frames.read(&mut reader, 4))
2610 .await
2611 .is_err()
2612 );
2613 input.write_all(b"34\n").await.unwrap();
2614 assert!(
2615 matches!(frames.read(&mut reader, 4).await.unwrap(), FrameRead::Frame(value) if value == b"1234")
2616 );
2617 input.write_all(b"123456789").await.unwrap();
2618 assert!(
2619 tokio::time::timeout(Duration::from_millis(10), frames.read(&mut reader, 4))
2620 .await
2621 .is_err()
2622 );
2623 input.write_all(b"\n{}\n").await.unwrap();
2624 assert!(matches!(
2625 frames.read(&mut reader, 4).await.unwrap(),
2626 FrameRead::TooLarge
2627 ));
2628 assert!(
2629 matches!(frames.read(&mut reader, 4).await.unwrap(), FrameRead::Frame(value) if value == b"{}")
2630 );
2631 }
2632
2633 #[tokio::test]
2634 async fn bounded_reader_discards_an_oversized_line() {
2635 let input = format!("{}\n{{}}\n", "x".repeat(10));
2636 let mut reader = BufReader::new(Cursor::new(input.into_bytes()));
2637 assert!(matches!(
2638 read_bounded_frame(&mut reader, 4).await.unwrap(),
2639 FrameRead::TooLarge
2640 ));
2641 match read_bounded_frame(&mut reader, 4).await.unwrap() {
2642 FrameRead::Frame(frame) => assert_eq!(frame, b"{}"),
2643 _ => panic!("expected the frame following the oversized line"),
2644 }
2645 }
2646
2647 #[tokio::test]
2648 async fn bounded_reader_accepts_exact_crlf_limit() {
2649 let mut reader = BufReader::new(Cursor::new(b"1234\r\n".to_vec()));
2650 match read_bounded_frame(&mut reader, 4).await.unwrap() {
2651 FrameRead::Frame(frame) => assert_eq!(frame, b"1234"),
2652 _ => panic!("expected an exact-limit frame"),
2653 }
2654 }
2655
2656 #[tokio::test]
2657 async fn outbound_byte_backpressure_is_cancellation_aware() {
2658 let (output, mut receiver) = outbound_channel(5);
2659 output.send(vec![0; 4], None).await.unwrap();
2660
2661 let cancellation = CancellationToken::new();
2662 let blocked = tokio::spawn({
2663 let output = output.clone();
2664 let cancellation = cancellation.clone();
2665 async move { output.send(vec![1; 4], Some(&cancellation)).await }
2666 });
2667 tokio::task::yield_now().await;
2668 assert!(!blocked.is_finished());
2669
2670 cancellation.cancel();
2671 assert_eq!(blocked.await.unwrap(), Err(OutboundSendError::Cancelled));
2672
2673 drop(receiver.recv().await.unwrap());
2674 output.send(vec![2; 4], None).await.unwrap();
2675 }
2676
2677 #[tokio::test]
2678 async fn outbound_control_send_times_out_under_byte_backpressure() {
2679 let (output, _receiver) = outbound_channel(5);
2680 output.send(vec![0; 4], None).await.unwrap();
2681 let result = output
2682 .send_with_timeout(vec![1; 4], None, Duration::from_millis(10))
2683 .await;
2684 assert_eq!(result, Err(OutboundSendError::TimedOut));
2685 }
2686
2687 #[tokio::test]
2688 async fn active_turn_shutdown_aborts_after_grace_period() {
2689 let cancellation = CancellationToken::new();
2690 let dropped = Arc::new(AtomicBool::new(false));
2691 let (started_tx, started_rx) = oneshot::channel();
2692 let task = tokio::spawn({
2693 let dropped = Arc::clone(&dropped);
2694 async move {
2695 let _signal = DropSignal(dropped);
2696 let _ = started_tx.send(());
2697 pending::<()>().await;
2698 }
2699 });
2700 started_rx.await.unwrap();
2701
2702 let graceful = shutdown_active_turn(
2703 ActiveTurn {
2704 turn_id: "turn".into(),
2705 cancellation,
2706 task,
2707 },
2708 Duration::from_millis(10),
2709 )
2710 .await;
2711
2712 assert!(!graceful);
2713 assert!(dropped.load(Ordering::Acquire));
2714 }
2715
2716 #[tokio::test]
2717 async fn writer_shutdown_aborts_after_grace_period() {
2718 let dropped = Arc::new(AtomicBool::new(false));
2719 let (started_tx, started_rx) = oneshot::channel();
2720 let writer = tokio::spawn({
2721 let dropped = Arc::clone(&dropped);
2722 async move {
2723 let _signal = DropSignal(dropped);
2724 let _ = started_tx.send(());
2725 pending::<std::io::Result<()>>().await
2726 }
2727 });
2728 started_rx.await.unwrap();
2729
2730 let result = shutdown_writer(writer, Duration::from_millis(10)).await;
2731
2732 assert!(result.is_err());
2733 assert!(dropped.load(Ordering::Acquire));
2734 }
2735
2736 #[tokio::test]
2737 async fn workspace_projects_list_their_agent_skills_for_delegation() {
2738 use std::os::unix::fs::symlink;
2739 let temporary = tempfile::tempdir().unwrap();
2740 let workspace = temporary.path().canonicalize().unwrap();
2741 let outside = tempfile::tempdir().unwrap();
2742 let outside = outside.path().canonicalize().unwrap();
2743 let write_skill = |directory: &Path, description: &str| {
2744 std::fs::create_dir_all(directory).unwrap();
2745 std::fs::write(
2746 directory.join("SKILL.md"),
2747 format!(
2748 "---\nname: skill\ndescription: {description}\n---\nBody of {description}\n"
2749 ),
2750 )
2751 .unwrap();
2752 };
2753 write_skill(
2754 &workspace.join("scv/.agents/skills/feature-flow"),
2755 "Land SCV",
2756 );
2757 std::fs::create_dir_all(workspace.join("scv/.claude/skills")).unwrap();
2758 symlink(
2759 "../../.agents/skills/feature-flow",
2760 workspace.join("scv/.claude/skills/feature-flow"),
2761 )
2762 .unwrap();
2763 write_skill(
2764 &workspace.join("web/.claude/skills/deploy"),
2765 "Deploy the site",
2766 );
2767 write_skill(
2769 &workspace.join("scv-topic/.agents/skills/feature-flow"),
2770 "Land SCV from a worktree",
2771 );
2772 std::fs::write(
2773 workspace.join("scv-topic/.git"),
2774 "gitdir: ../scv/.git/worktrees/scv-topic\n",
2775 )
2776 .unwrap();
2777 write_skill(&workspace.join(".agents/skills/triage"), "Root triage");
2778 write_skill(&workspace.join(".agents/skills/notes"), "Root notes");
2779 write_skill(&workspace.join(".scv/skills/triage"), "SCV triage");
2780 write_skill(&workspace.join(".hidden/.agents/skills/secret"), "Hidden");
2781 write_skill(&outside.join(".agents/skills/evil"), "Outside");
2782 symlink(&outside, workspace.join("escape")).unwrap();
2783 std::fs::create_dir_all(workspace.join("rogue/.agents")).unwrap();
2784 symlink(
2785 outside.join(".agents/skills"),
2786 workspace.join("rogue/.agents/skills"),
2787 )
2788 .unwrap();
2789 std::fs::create_dir_all(workspace.join("sneaky/.agents/skills/leak")).unwrap();
2790 symlink(
2791 outside.join(".agents/skills/evil/SKILL.md"),
2792 workspace.join("sneaky/.agents/skills/leak/SKILL.md"),
2793 )
2794 .unwrap();
2795 std::fs::write(workspace.join("file"), "not a project").unwrap();
2796 let mut config = Config::default();
2797 config.skills.user_dir = workspace.join("no-user-skills");
2798
2799 let skills = discover_skills(&workspace, &config, true).unwrap();
2800 let mut names: Vec<_> = skills.map.keys().cloned().collect();
2801 names.sort();
2802 assert_eq!(names, ["notes", "scv:feature-flow", "triage", "web:deploy"]);
2803 assert_eq!(
2804 skills.map["triage"],
2805 workspace.join(".scv/skills/triage/SKILL.md")
2806 );
2807 assert_eq!(
2808 skills.project_listing,
2809 "- notes (workspace root): Root notes\n\
2810 - scv:feature-flow (project scv): Land SCV\n\
2811 - web:deploy (project web): Deploy the site\n"
2812 );
2813 let listings = SkillListings {
2814 listing: skills.listing.clone(),
2815 project_listing: skills.project_listing.clone(),
2816 };
2817 let agents = ["agent_claude".to_owned(), "agent_pi".to_owned()];
2818 let prompt = build_system_prompt(
2819 &workspace,
2820 &config,
2821 &listings,
2822 &PromptContext {
2823 agents: &agents,
2824 background: true,
2825 channel: None,
2826 },
2827 )
2828 .unwrap();
2829 assert!(prompt.contains("# Project skills"));
2830 assert!(
2831 prompt.contains("such as agent_claude or agent_pi, set its cwd to the skill's project")
2832 );
2833 assert!(!prompt.contains("agent_codex"), "{prompt}");
2835 let without_agents = build_system_prompt(
2836 &workspace,
2837 &config,
2838 &listings,
2839 &PromptContext {
2840 agents: &[],
2841 background: false,
2842 channel: None,
2843 },
2844 )
2845 .unwrap();
2846 assert!(
2847 !without_agents.contains("delegate with"),
2848 "{without_agents}"
2849 );
2850 assert!(without_agents.contains("read_skill loads one for reference"));
2851
2852 let registry = builtin_registry(
2853 config.tools(),
2854 skills.map,
2855 skills.roots,
2856 config.skills.max_skill_bytes,
2857 HashMap::new(),
2858 )
2859 .unwrap();
2860 let read_skill = registry.get("read_skill").unwrap();
2861 let loaded = read_skill
2862 .execute(
2863 serde_json::json!({"name":"scv:feature-flow"}),
2864 scv_core::ToolContext::new(workspace.clone(), CancellationToken::new()),
2865 )
2866 .await
2867 .unwrap();
2868 assert!(loaded.content.contains("Body of Land SCV"));
2869
2870 let tool_free = discover_skills(&workspace, &config, false).unwrap();
2871 assert!(tool_free.project_listing.is_empty());
2872 assert!(!tool_free.map.contains_key("scv:feature-flow"));
2873 config.skills.scan_projects = false;
2874 let disabled = discover_skills(&workspace, &config, true).unwrap();
2875 assert!(disabled.project_listing.is_empty());
2876 config.skills.scan_projects = true;
2877 config.skills.max_skills = 3;
2878 let capped = discover_skills(&workspace, &config, true).unwrap();
2879 assert_eq!(capped.map.len(), 3);
2880 assert!(capped.map.contains_key("scv:feature-flow"));
2881 assert!(!capped.map.contains_key("web:deploy"));
2882 }
2883
2884 fn prompt_for(config: &Config, context: &PromptContext<'_>) -> String {
2885 let workspace = tempfile::tempdir().unwrap();
2886 let listings = SkillListings {
2887 listing: String::new(),
2888 project_listing: String::new(),
2889 };
2890 build_system_prompt(workspace.path(), config, &listings, context).unwrap()
2891 }
2892
2893 #[test]
2894 fn the_prompt_teaches_delegate_first_only_when_agents_can_run_in_the_background() {
2895 let mut config = Config::default();
2896 config.agent.prefer = vec!["pi".into(), "codex".into(), "grok".into()];
2897 let agents = ["agent_codex".to_owned(), "agent_grok".to_owned()];
2898 let prompt = prompt_for(
2899 &config,
2900 &PromptContext {
2901 agents: &agents,
2902 background: true,
2903 channel: None,
2904 },
2905 );
2906 assert!(prompt.starts_with(&config.agent.system_prompt), "{prompt}");
2907 assert!(
2908 prompt.contains("agent_codex (Codex), agent_grok (Grok Build)"),
2909 "{prompt}"
2910 );
2911 assert!(
2913 prompt.contains("The user prefers agent_codex, agent_grok, in that order"),
2914 "{prompt}"
2915 );
2916 assert!(prompt.contains("background set to true"), "{prompt}");
2917 assert!(prompt.contains("job handle"), "{prompt}");
2918 assert!(prompt.contains("agent_cancel"), "{prompt}");
2919 assert!(prompt.contains("[SCV background report]"), "{prompt}");
2920 assert!(!prompt.contains("# Chat channel"), "{prompt}");
2921 assert!(
2923 prompt.contains(
2924 "If an agent declines a request, tell the user what it said; don't pass the \
2925 request to another agent on your own. If the user then asks for a specific \
2926 agent, use it."
2927 ),
2928 "{prompt}"
2929 );
2930 assert!(
2931 prompt.contains("a preferred one is unavailable"),
2932 "{prompt}"
2933 );
2934 for loud in ["CRITICAL", "MUST", "IMPORTANT", "NEVER"] {
2936 assert!(!prompt.contains(loud), "{loud} in {prompt}");
2937 }
2938
2939 let foreground = prompt_for(
2940 &Config::default(),
2941 &PromptContext {
2942 agents: &agents,
2943 background: false,
2944 channel: None,
2945 },
2946 );
2947 assert!(foreground.contains("Hand substantial work to an agent"));
2948 assert!(foreground.contains("If an agent declines a request"));
2949 assert!(!foreground.contains("background set to true"));
2950 assert!(!foreground.contains("prefers"));
2951
2952 let tool_free = prompt_for(
2953 &Config::default(),
2954 &PromptContext {
2955 agents: &[],
2956 background: false,
2957 channel: None,
2958 },
2959 );
2960 assert!(!tool_free.contains("# Delegating work"), "{tool_free}");
2961 }
2962
2963 #[test]
2964 fn chat_sessions_are_told_their_channel_and_how_replies_are_read() {
2965 let agents = ["agent_claude".to_owned()];
2966 let owner = prompt_for(
2967 &Config::default(),
2968 &PromptContext {
2969 agents: &agents,
2970 background: true,
2971 channel: Some("WeChat"),
2972 },
2973 );
2974 assert!(owner.contains("# Chat channel"), "{owner}");
2975 assert!(owner.contains("takes place on WeChat"), "{owner}");
2976 assert!(owner.contains("plain text"), "{owner}");
2977 assert!(owner.contains("never see your tool calls"), "{owner}");
2978 assert!(owner.contains("# Delegating work"), "{owner}");
2979 let guest = prompt_for(
2981 &Config::default(),
2982 &PromptContext {
2983 agents: &[],
2984 background: false,
2985 channel: Some("Feishu"),
2986 },
2987 );
2988 assert!(guest.contains("takes place on Feishu"), "{guest}");
2989 assert!(!guest.contains("# Delegating work"), "{guest}");
2990 assert!(valid_channel_name("Lark"));
2991 for bad in [
2992 "",
2993 " ",
2994 "We\nChat",
2995 &"x".repeat(scv_protocol::MAX_CHANNEL_NAME_BYTES + 1),
2996 ] {
2997 assert!(!valid_channel_name(bad), "{bad:?}");
2998 }
2999 }
3000
3001 #[tokio::test]
3002 async fn background_requests_get_only_the_unattended_answer() {
3003 let request = |risk| ApprovalRequest {
3004 call_id: "job-1".into(),
3005 name: "agent_codex".into(),
3006 risk,
3007 cwd: PathBuf::from("/"),
3008 summary: "nested".into(),
3009 };
3010 let decide = |policy, client_approves_all, risk| async move {
3011 UnattendedGate {
3012 policy,
3013 client_approves_all,
3014 }
3015 .approve(request(risk), CancellationToken::new())
3016 .await
3017 .unwrap()
3018 };
3019 use ApprovalPolicy::{Always, Never, OnRisk};
3020 use ToolRisk::{Process, ReadOnly};
3021 assert!(decide(OnRisk, true, Process).await);
3024 assert!(decide(Always, true, Process).await);
3025 assert!(decide(Always, true, ReadOnly).await);
3026 assert!(
3027 !decide(Never, true, Process).await,
3028 "never beyond the policy"
3029 );
3030 assert!(decide(Never, true, ReadOnly).await);
3031 assert!(!decide(OnRisk, false, Process).await);
3034 assert!(decide(OnRisk, false, ReadOnly).await);
3035 assert!(!decide(Always, false, ReadOnly).await);
3036 assert!(!decide(Never, false, Process).await);
3037 }
3038
3039 #[test]
3040 fn signed_out_agents_with_a_local_sign_in_check_are_not_offered() {
3041 let home = tempfile::tempdir().unwrap();
3042 let config = Config {
3043 instance_home: home.path().to_owned(),
3044 ..Config::default()
3045 };
3046 let offered = offered_adapters(&config);
3047 for hidden in ["agent_dsh", "agent_pi", "agent_grok", "agent_scv"] {
3049 assert!(!offered.contains_key(hidden), "{hidden} offered");
3050 }
3051 assert!(offered.contains_key("agent_claude"));
3054 assert!(offered.contains_key("agent_codex"));
3055 let dsh = home.path().join("agents/dsh/.dsh");
3057 std::fs::create_dir_all(&dsh).unwrap();
3058 std::fs::write(
3059 dsh.join(".credentials.yaml"),
3060 "version: 1\n\nrefs:\n DEEPSEEK_API_KEY: test-only\n",
3061 )
3062 .unwrap();
3063 assert!(offered_adapters(&config).contains_key("agent_dsh"));
3064 }
3065
3066 #[tokio::test]
3067 async fn daemon_control_lists_and_stops_delegations() {
3068 use std::os::unix::process::CommandExt as _;
3069 let home = tempfile::tempdir().unwrap();
3070 let registry = DelegationRegistry::new(home.path());
3071 let components = Arc::new(Mutex::new(components::Components::new(
3072 PathBuf::from("/unused.sock"),
3073 PathBuf::from("/"),
3074 )));
3075 let mut owner = std::process::Command::new("sleep")
3077 .arg("30")
3078 .spawn()
3079 .unwrap();
3080 let mut agent = std::process::Command::new("sleep")
3081 .arg("30")
3082 .process_group(0)
3083 .spawn()
3084 .unwrap();
3085 let identity = |pid| delegations::ProcessIdentity::of(pid).unwrap();
3086 let record = delegations::DelegationRecord {
3087 handle: "codex-a1b2c3".into(),
3088 agent: "codex".into(),
3089 instance: registry.instance().into(),
3090 session: "session".into(),
3091 owner: identity(owner.id()),
3092 process: identity(agent.id()),
3093 pgid: agent.id(),
3094 cwd: "/work/project\u{7}".into(),
3095 started_unix: 1,
3096 depth: 1,
3097 conversation: Some("codex-2".into()),
3098 turn: Some(3),
3099 };
3100 std::fs::create_dir_all(registry.record_dir()).unwrap();
3101 std::fs::write(
3102 registry.record_dir().join("codex-a1b2c3.json"),
3103 serde_json::to_vec(&record).unwrap(),
3104 )
3105 .unwrap();
3106 let control = |command| daemon_control(&components, ®istry, command);
3107 let Ok(status) = control(DaemonCommand::Status).await else {
3108 panic!("status failed");
3109 };
3110 assert_eq!(status.delegations.active, 1);
3111 assert!(status.delegations.entries.is_empty());
3112 let Ok(status) = control(DaemonCommand::Delegations { all: false }).await else {
3113 panic!("listing failed");
3114 };
3115 let [entry] = status.delegations.entries.as_slice() else {
3116 panic!("{:?}", status.delegations);
3117 };
3118 assert_eq!(entry.handle, "codex-a1b2c3");
3119 assert_eq!(entry.conversation.as_deref(), Some("codex-2"));
3120 assert_eq!(entry.turn, Some(3));
3121 assert_eq!(entry.pid, agent.id());
3122 assert_eq!(entry.owner_pid, owner.id());
3123 assert!(!entry.orphaned);
3124 assert_eq!(entry.processes, 1);
3125 for command in [
3126 DaemonCommand::DelegationKill {
3127 handle: Some("codex-nosuch".into()),
3128 orphans: false,
3129 },
3130 DaemonCommand::DelegationKill {
3131 handle: None,
3132 orphans: false,
3133 },
3134 ] {
3135 assert!(matches!(
3136 control(command).await,
3137 Err(ControlFailure::Delegation(_))
3138 ));
3139 }
3140 let Ok(status) = control(DaemonCommand::DelegationKill {
3141 handle: Some("codex-a1b2c3".into()),
3142 orphans: false,
3143 })
3144 .await
3145 else {
3146 panic!("kill failed");
3147 };
3148 assert_eq!(status.delegations.killed, ["codex-a1b2c3"]);
3149 assert!(agent.wait().unwrap().code().is_none());
3150 owner.kill().unwrap();
3153 owner.wait().unwrap();
3154 let Ok(status) = control(DaemonCommand::Delegations { all: true }).await else {
3155 panic!("listing failed");
3156 };
3157 assert!(status.delegations.entries[0].orphaned);
3158 assert_eq!(status.delegations.active, 0);
3159 let Ok(_) = control(DaemonCommand::DelegationKill {
3160 handle: None,
3161 orphans: true,
3162 })
3163 .await
3164 else {
3165 panic!("orphan sweep failed");
3166 };
3167 assert!(registry.list(true).is_empty());
3168 }
3169}