Skip to main content

scv_server/
lib.rs

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