Skip to main content

scv_server/
lib.rs

1//! SCV's authoritative stdio server.
2
3mod 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
37/// Build a command for a native agent's CLI with the same private home and
38/// cleaned environment the daemon's `agent_<name>` tool uses, so the agent's
39/// own sign-in stores credentials where delegated runs will find them.
40/// Project configuration cannot set `[agents]`, so none is read.
41pub 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
62/// Where `agent`'s executable resolves, as the daemon would find it.
63pub 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
75/// The prepared private agent home for `agent`.
76pub 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
86/// Remove delegated-conversation transcripts older than `older_than` from
87/// the agent homes (`agent`, or every agent that keeps them), keeping any a
88/// live conversation still uses. Returns each agent's report.
89pub 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
116/// Parse a `scv agents gc --older-than` age such as `30d`.
117pub 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
126/// Store `key` in the agent's native credential file inside its agent home.
127pub 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
131/// Whether the agent's stored credentials exist, with display lines that
132/// never contain secrets.
133pub fn agent_stored_status(agent: &str, store: adapters::KeyStore) -> Result<(bool, Vec<String>)> {
134    agents::stored_status(store, &key_store_home(agent)?)
135}
136
137/// Remove the agent's stored credentials from its agent home.
138pub fn remove_agent_credentials(agent: &str, store: adapters::KeyStore) -> Result<Vec<String>> {
139    agents::remove_stored(store, &key_store_home(agent)?)
140}
141
142/// Point SCV's pi at an OpenAI-compatible endpoint.
143pub fn configure_pi_endpoint(endpoint: &Endpoint, key: &str) -> Result<Vec<String>> {
144    agents::configure_pi_endpoint(&pi_agent_dir()?, endpoint, key)
145}
146
147/// Point SCV's pi at SCV's own active provider: its base URL, model, and key
148/// (read from `api_key`, or from the `api_key_env` variable now, since
149/// delegated agents never inherit key variables).
150pub 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
176/// The key of SCV's own provider: `api_key`, or the `api_key_env` variable
177/// read now, since delegated agents never inherit key variables.
178fn 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
194/// Digest of what a provider import copies to `agent`: SCV's provider
195/// settings and key, as each agent receives them.
196fn 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
214/// How `agent`'s imported copy compares with its source now, as one display
215/// line without secrets; `None` when nothing was imported.
216pub 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
225/// Give the nested SCV (`agent_scv`) its own copy of SCV's active provider,
226/// in `$SCV_HOME/agents/scv/config.toml` (mode 0600).
227pub 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
265/// Copy the user's own Codex setup from `source` into SCV's private Codex
266/// agent home: `config.toml`, and `auth.json` only when it holds an API key.
267/// Returns display lines that never contain secret values.
268pub 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
277/// Remember which files an import copied from `source`, so a later change
278/// there shows up as a stale copy.
279fn 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
290/// Copy the user's own Grok `config.toml` from `source` (a Grok home) into
291/// SCV's private Grok home, keeping settings only SCV's copy has. Returns
292/// display lines that never contain secret values.
293pub 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
310/// Return the user service name for the selected SCV instance.
311pub 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    // Without a daemon, a later `scv exec` is what cleans up after an earlier
367    // one that was killed; this runs alongside the session.
368    tokio::spawn(reconcile_delegations(Arc::clone(&registry)));
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
384/// Return the local Unix socket used by the SCV daemon and TUI.
385pub fn default_socket_path() -> Result<PathBuf> {
386    scv_client::default_socket_path()
387}
388
389/// Run the authoritative server on the local Unix socket.
390pub 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    // Nothing reads an older release's files; say so once rather than let
404    // them look like live configuration.
405    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    // Descendants a delegated agent leaves behind reparent to the daemon, not init.
444    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(&registry);
449    let delegation_cancel = cancellation.clone();
450    let mut delegation_task = tokio::spawn(async move {
451        // The first tick is immediate: orphans from before a restart go first.
452        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(&registry);
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
535/// The delegation registry for this process's SCV instance.
536fn 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
542/// Stop orphaned delegations of this instance and log what was stopped.
543async 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
566/// Why a daemon control request failed.
567enum ControlFailure {
568    /// A delegation request the client can correct; the message is safe to show.
569    Delegation(String),
570    Component,
571}
572
573/// Apply a daemon control command, adding the instance's delegations.
574async 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    // Woken when a background job finishes; set until its report turn starts.
673    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, &registry, 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), &registry, 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(&current.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(&current.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(&current.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(&current.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(&current.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(&current.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(&current.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(&current.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(&current.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                    // Report finished background jobs once the user's own work is done.
959                    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/// A persistent decoder keeps consumed partial bytes across select cancellation.
1148#[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
1202/// A persistent advisory lock closes the stale-socket unlink/bind race.
1203struct 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        // SAFETY: flock operates on this owned, live file descriptor.
1216        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        // SAFETY: the descriptor remains live until this drop returns.
1226        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 agent jobs, shared with the session's tools.
1249    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        // Validate the destination while the source is still present. This keeps
1321        // the operation atomic and handles a self move as a no-op reorder.
1322        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
1422/// What a connection needs to start a turn in its session.
1423struct 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    /// Announce and run one turn of `current` for `prompt`.
1433    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(&current.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(&current.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(&current.runtime);
1473        let history = Arc::clone(&current.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    /// Start a turn reporting background jobs the model has not seen yet,
1501    /// or `None` when every finished job was already seen.
1502    async fn report_background(&self, current: &Session) -> Result<Option<ActiveTurn>> {
1503        let Some(jobs) = &current.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
1528/// The next background-job wake-up, or never without a receiver.
1529async 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/// What a client declared about itself in `session.start`.
1550#[derive(Debug, Default)]
1551struct SessionClient {
1552    /// The chat channel the session answers on, such as `WeChat`.
1553    channel: Option<String>,
1554    /// The client approves every approval request without asking anyone.
1555    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
1564/// The configured agents to offer: signed-out ones are left out when their
1565/// sign-in state is a local file SCV can check cheaply; the rest are offered
1566/// and fail with a sign-in hint if they turn out to be signed out.
1567fn 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
1586/// Agent tools that delegate work, as opposed to observing or stopping jobs.
1587fn 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
1601/// `delegation_depth` is the depth the client declared in `session.start`
1602/// (0 for a direct client); the session's delegated runs count from it.
1603async 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
1714/// The skill listings a session's system prompt carries.
1715struct SkillListings {
1716    listing: String,
1717    project_listing: String,
1718}
1719
1720/// What the system prompt tells the model about its situation.
1721struct PromptContext<'a> {
1722    /// Agent tools this session offers, such as `agent_codex`, sorted.
1723    agents: &'a [String],
1724    /// Whether agent calls can run in the background.
1725    background: bool,
1726    /// The chat channel the session answers on.
1727    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
1799/// How the main agent works with delegated agents. Written to explain why,
1800/// since the model follows guidance it understands more reliably.
1801fn 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
1855/// Skills found at session start: the names `read_skill` serves, the roots it
1856/// revalidates them against, and their system-prompt listings.
1857struct DiscoveredSkills {
1858    map: SkillMap,
1859    roots: Vec<PathBuf>,
1860    listing: String,
1861    project_listing: String,
1862}
1863
1864/// Agent-native skill directories, relative to a project, that Codex and
1865/// Claude Code load from their working directory.
1866const PROJECT_SKILL_DIRS: [&str; 2] = [".agents/skills", ".claude/skills"];
1867/// Workspace entries and child projects inspected for project skills, so a
1868/// large workspace such as a home directory costs bounded lookups.
1869const MAX_WORKSPACE_ENTRIES: usize = 4096;
1870const MAX_SKILL_PROJECTS: usize = 256;
1871/// Bytes read to find a project skill's description, and its listed length.
1872const 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    // Project skills are only actionable by delegating, so tool-free sessions
1924    // neither list them nor learn the workspace's project names.
1925    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
1938/// List the agent skills of the workspace and its immediate, non-hidden child
1939/// projects. A child's skills are named `<project>:<skill>`. Everything
1940/// resolves inside the workspace, SKILL.md files that resolve to the same file
1941/// (such as a `.claude/skills` link to `.agents/skills`) count once, and
1942/// unreadable entries are skipped so one broken project cannot stop a session.
1943fn 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                // SCV's own and the user's skills keep their names.
2013                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
2071/// Cut progress text to `MAX_PROGRESS_EVENT_BYTES` on a character boundary.
2072fn 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                // Tools report through a bounded sink; bound again here so a
2137                // custom tool can never grow a frame past the documented size.
2138                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
2209/// The decision `policy` makes for `risk` on its own, or `None` when it
2210/// asks the client.
2211fn 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
2226/// Decides a background job's nested approval requests, which outlive the
2227/// turn that could carry them to the client. Each gets the answer the
2228/// session would give without asking a person: the policy's own decision,
2229/// else the client's declared blanket answer (`auto_approve`), else a denial.
2230/// It never grants more than the same request would get in the foreground.
2231struct 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    /// A delegation registry in a private temporary instance home.
2360    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        // Only agents this session offers are named.
2722        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        // Preferences name only offered agents, in the user's order.
2800        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        // Calm guidance: no shouted rules.
2810        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        // A tool-free chat session still learns how its replies are read.
2854        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        // An owner chat session's client approves everything, so background
2896        // requests get that answer, within the policy.
2897        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        // A client that asks a person (the TUI) or a tool-free guest: only
2906        // what the policy grants on its own.
2907        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        // Nothing stored for dsh, pi, grok, or the nested SCV: all hidden.
2922        for hidden in ["agent_dsh", "agent_pi", "agent_grok", "agent_scv"] {
2923            assert!(!offered.contains_key(hidden), "{hidden} offered");
2924        }
2925        // Claude and Codex report sign-in through their own CLI, which is
2926        // too slow to run at every session start, so they stay offered.
2927        assert!(offered.contains_key("agent_claude"));
2928        assert!(offered.contains_key("agent_codex"));
2929        // A stored dsh key makes it available.
2930        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        // A run owned by another live SCV process of the same instance.
2950        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, &registry, 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        // Its live owner removes the record itself; once the owner is gone
3025        // the record is an orphan that an orphan sweep removes.
3026        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}