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