Skip to main content

leviath_cli/commands/agent_client/
mod.rs

1//! `lev agent-client` - serve a Leviath agent over the Agent **Client** Protocol.
2//!
3//! ## Which protocol this is
4//!
5//! This speaks the [Agent Client Protocol][acp]: JSON-RPC 2.0, newline-delimited,
6//! over **stdio**. It is the protocol Zed and Gas City use to drive a headless
7//! agent as a child process - `initialize` / `session/new` / `session/prompt` /
8//! `session/cancel`, with `session/update` notifications streaming output back.
9//!
10//! It is **not** the Agent *Communication* Protocol (a REST + SSE API from the
11//! BeeAI project). The two share the acronym "ACP" and nothing else. This command
12//! is the one that actually integrates Leviath with Gas City.
13//!
14//! ## How it works
15//!
16//! `lev agent-client` is a thin front end over the shared-world daemon, exactly
17//! like `lev run` / `lev serve` / `lev dash`: it owns no agent world of its own.
18//! A `session/prompt` spawns (or, on later prompts, messages) an agent in the
19//! daemon over the control socket, then translates the daemon's live
20//! [`WorldEvent`] stream and the run's per-stage output into `session/update`
21//! notifications until the run finishes or parks.
22//!
23//! [`WorldEvent`]: leviath_runtime::host::WorldEvent
24//!
25//! The protocol logic is [`serve_over`], which takes its reader/writer generically
26//! and erases them to trait objects internally, so the whole
27//! handshake→prompt→stream sequence is driven in tests over an in-memory duplex
28//! against a fake daemon - no process, no terminal, no real stdio.
29//!
30//! [acp]: https://agentclientprotocol.com
31
32mod session;
33mod translate;
34
35use std::path::PathBuf;
36
37use clap::Args;
38use leviath_agent_client::{
39    AgentCapabilities, AgentInfo, ContentBlock, InitializeParams, InitializeResult, JsonRpcMessage,
40    PROTOCOL_VERSION, PromptCapabilities, RequestPermissionResult, SessionCancelParams,
41    SessionNewParams, SessionNewResult, SessionPromptParams, SessionPromptResult, SessionUpdate,
42    SessionUpdateParams, StopReason, error_codes, flatten_prompt, is_permission_request,
43    parse_region_markers, permission_request,
44};
45use leviath_core::interaction::{ApprovalScope, InteractionRequest, InteractionResponse};
46use leviath_core::run_meta::RunStatus;
47use leviath_runtime::control_socket::{ControlClient, ControlRequest, ControlResponse};
48use leviath_runtime::host::WorldEvent;
49use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWrite, AsyncWriteExt};
50
51use self::mapping::{PermissionChoice, interpret_permission};
52use self::session::{ResolvedBlueprint, resolve_blueprint, spawn_args};
53use self::translate::{StageTail, split_chunks};
54
55/// How often, absent a daemon event, the loop flushes newly-written run output.
56const OUTPUT_POLL: std::time::Duration = std::time::Duration::from_millis(250);
57
58/// Arguments for `lev agent-client`.
59#[derive(Args, Debug, Clone, Default)]
60pub struct AgentClientArgs {
61    /// Blueprint to serve: an installed agent name, or a path to one. When
62    /// omitted, each session's working directory is searched for an
63    /// `agent.leviath`.
64    #[arg(long)]
65    pub agent: Option<String>,
66
67    /// Approve every tool call without prompting (recommended when the host does
68    /// not implement `session/request_permission`, e.g. Gas City).
69    #[arg(long)]
70    pub yolo: bool,
71
72    /// Allow a tool outright (repeatable).
73    #[arg(long)]
74    pub allow: Vec<String>,
75
76    /// Override the blueprint's max sub-agent tree depth.
77    #[arg(long)]
78    pub max_depth: Option<usize>,
79
80    /// Refuse the blueprint's `seed = { command = "..." }` regions. Those run a
81    /// shell command at spawn - before the first inference, and so before any
82    /// approval prompt.
83    #[arg(long)]
84    pub no_seed_commands: bool,
85}
86
87/// The protocol server. Generic over transport at the boundary, then erased to
88/// trait objects internally (see the body).
89///
90/// Reads newline-delimited JSON-RPC messages from `reader`, drives the session
91/// state machine, and writes responses/notifications to `writer`. `control`
92/// reaches the shared-world daemon; `runs_dir` is the run-state root the output
93/// tailer reads from (injected so tests use a temp dir). Returns `Ok(())` when
94/// the client closes the input stream (EOF).
95pub async fn serve_over<R, W>(
96    reader: R,
97    writer: W,
98    control: ControlClient,
99    args: AgentClientArgs,
100    runs_dir: PathBuf,
101    default_cwd: String,
102) -> anyhow::Result<()>
103where
104    R: AsyncBufRead + Send + 'static,
105    W: AsyncWrite + Send + 'static,
106{
107    // Erase the reader/writer to trait objects so the (large, multi-branch)
108    // `Server` state machine has exactly ONE monomorphization regardless of the
109    // concrete transport. This is the same technique `leviath-cli`'s `serve`
110    // module uses on its shutdown future: without it, every distinct test
111    // reader/writer type spawns an unused `Server` instantiation whose
112    // never-called methods read as uncovered regions under the coverage gate.
113    let mut reader: BoxReader = Box::pin(reader);
114    let mut server = Server {
115        control,
116        args,
117        runs_dir,
118        writer: Box::pin(writer),
119        caps_present: false,
120        session: None,
121        next_request_id: 0,
122        io_alive: true,
123        default_cwd,
124    };
125    server.run(&mut reader).await;
126    Ok(())
127}
128
129/// The reader half, erased to a single trait-object type.
130type BoxReader = std::pin::Pin<Box<dyn AsyncBufRead + Send>>;
131/// The writer half, erased to a single trait-object type.
132type BoxWriter = std::pin::Pin<Box<dyn AsyncWrite + Send>>;
133
134/// The active session's mutable state. Gas City and Zed drive one session per
135/// process, so a single slot suffices; a fresh `session/new` replaces it.
136struct ActiveSession {
137    /// The protocol session id we minted.
138    session_id: String,
139    /// The blueprint this session runs.
140    blueprint: ResolvedBlueprint,
141    /// The session's working directory.
142    cwd: String,
143    /// The daemon run id, once the first prompt has spawned it.
144    run_id: Option<String>,
145}
146
147/// The protocol server: transport, daemon handle, and session state.
148struct Server {
149    control: ControlClient,
150    args: AgentClientArgs,
151    runs_dir: PathBuf,
152    writer: BoxWriter,
153    /// Whether the client advertised any capabilities at `initialize`. Hosts that
154    /// implement the client-side methods (and so can answer an agent-initiated
155    /// `session/request_permission`) send them; Gas City sends none, so tool
156    /// approvals are surfaced as output and parked instead of deadlocking on a
157    /// request the host will never answer.
158    caps_present: bool,
159    session: Option<ActiveSession>,
160    /// Monotonic id source for agent→client requests.
161    next_request_id: i64,
162    /// Working directory to use when a `session/new` omits (or empties) `cwd` -
163    /// the directory `lev agent-client` was launched from. Without this the
164    /// agent's workdir was an empty string, so it ran in the daemon's directory
165    /// rather than the caller's.
166    default_cwd: String,
167    /// Whether the output stream is still writable. Output is best-effort: a
168    /// failed write means the client is gone, so this flips to `false` and the
169    /// server winds down rather than propagating an error per write site (which
170    /// mirrors the WebSocket server's `send(...).is_err()` handling).
171    io_alive: bool,
172}
173
174/// The outcome of getting a run going for a prompt turn.
175enum RunStart {
176    /// The run is live under this id; stream it.
177    Ready(String),
178    /// A follow-up message could not be delivered (the agent already finished).
179    MessageUndeliverable,
180    /// The daemon refused to spawn the run, or was unreachable.
181    SpawnFailed,
182}
183
184/// What became of an interaction raised mid-turn.
185enum InteractionOutcome {
186    /// The interaction was resolved in-turn (via `session/request_permission`),
187    /// or the client cannot answer it and Leviath will handle it out of band -
188    /// either way the turn keeps streaming until the run reaches a done state.
189    Continue,
190    /// The interaction was surfaced as output and the turn ends now with this
191    /// reason, leaving the run parked for the client's next prompt. Used only for
192    /// clients that drive their own conversation (they advertised capabilities);
193    /// the client re-prompts with the answer.
194    Park(StopReason),
195}
196
197impl Server {
198    /// The top-level read/dispatch loop. Returns when the client closes stdin or
199    /// the output stream breaks.
200    async fn run(&mut self, reader: &mut BoxReader) {
201        while self.io_alive {
202            let Some(line) = read_line(reader).await else {
203                break; // EOF or a read error - the client is gone
204            };
205            let trimmed = line.trim();
206            if trimmed.is_empty() {
207                continue; // hosts may emit blank keep-alive lines
208            }
209            match serde_json::from_str::<JsonRpcMessage>(trimmed) {
210                Ok(msg) => self.dispatch(reader, msg).await,
211                Err(_) => {
212                    self.write(&JsonRpcMessage::error_response(
213                        serde_json::Value::Null,
214                        error_codes::PARSE_ERROR,
215                        "invalid JSON",
216                    ))
217                    .await;
218                }
219            }
220        }
221    }
222
223    /// Route one parsed message.
224    async fn dispatch(&mut self, reader: &mut BoxReader, msg: JsonRpcMessage) {
225        match (msg.method.as_deref(), msg.id.clone()) {
226            // ── Requests (have an id) ──
227            (Some("initialize"), Some(id)) => self.on_initialize(id, msg.params).await,
228            (Some("session/new"), Some(id)) => self.on_session_new(id, msg.params).await,
229            (Some("session/prompt"), Some(id)) => {
230                self.on_session_prompt(reader, id, msg.params).await
231            }
232            (Some(_other), Some(id)) => {
233                self.write(&JsonRpcMessage::error_response(
234                    id,
235                    error_codes::METHOD_NOT_FOUND,
236                    "method not supported",
237                ))
238                .await
239            }
240            // ── Notifications (no id) ──
241            (Some("session/cancel"), None) => self.on_cancel_notification(msg.params).await,
242            // Any other notification (`initialized`, unknown) is ignored, as is a
243            // stray response with neither method nor id.
244            _ => {}
245        }
246    }
247
248    /// `initialize`: advertise this agent's identity and capabilities, and note
249    /// whether the client advertised its own.
250    async fn on_initialize(&mut self, id: serde_json::Value, params: Option<serde_json::Value>) {
251        let params: InitializeParams = params
252            .and_then(|p| serde_json::from_value(p).ok())
253            .unwrap_or_default();
254        self.caps_present = params.client_capabilities.is_some();
255        let result = InitializeResult {
256            protocol_version: PROTOCOL_VERSION,
257            agent_capabilities: AgentCapabilities {
258                load_session: false,
259                prompt_capabilities: PromptCapabilities {
260                    image: false,
261                    audio: false,
262                    embedded_context: true,
263                },
264            },
265            agent_info: AgentInfo {
266                name: "leviath".to_string(),
267                version: env!("CARGO_PKG_VERSION").to_string(),
268            },
269            auth_methods: vec![],
270        };
271        self.write(&JsonRpcMessage::response(id, &result)).await;
272    }
273
274    /// `session/new`: resolve the blueprint and open a session. No run is spawned
275    /// yet - that waits for the first prompt.
276    async fn on_session_new(&mut self, id: serde_json::Value, params: Option<serde_json::Value>) {
277        let params: SessionNewParams = params
278            .and_then(|p| serde_json::from_value(p).ok())
279            .unwrap_or_default();
280        if !params.mcp_servers.is_empty() {
281            // Leviath blueprints declare their own MCP servers; client-supplied
282            // ones are captured for visibility but not injected (see module docs).
283            let ignored = params.mcp_servers.len();
284            tracing::info!(
285                mcp_server_count = ignored,
286                "session/new supplied MCP servers; ignoring in favour of the blueprint's own"
287            );
288        }
289        // An absent/empty `cwd` falls back to the directory `lev agent-client`
290        // was launched from, so the agent's tools operate there rather than in
291        // the daemon's working directory.
292        let cwd = if params.cwd.trim().is_empty() {
293            self.default_cwd.clone()
294        } else {
295            params.cwd
296        };
297        match resolve_blueprint(self.args.agent.as_deref(), &cwd) {
298            Ok(blueprint) => {
299                let session_id = new_session_id(&blueprint.agent_name);
300                self.session = Some(ActiveSession {
301                    session_id: session_id.clone(),
302                    blueprint,
303                    cwd,
304                    run_id: None,
305                });
306                self.write(&JsonRpcMessage::response(
307                    id,
308                    &SessionNewResult { session_id },
309                ))
310                .await;
311            }
312            Err(e) => {
313                self.write(&JsonRpcMessage::error_response(
314                    id,
315                    error_codes::INVALID_PARAMS,
316                    format!("no blueprint for this session: {e}"),
317                ))
318                .await;
319            }
320        }
321    }
322
323    /// `session/prompt`: run one prompt turn end to end and report its stop reason.
324    async fn on_session_prompt(
325        &mut self,
326        reader: &mut BoxReader,
327        id: serde_json::Value,
328        params: Option<serde_json::Value>,
329    ) {
330        if self.session.is_none() {
331            self.write(&JsonRpcMessage::error_response(
332                id,
333                error_codes::INVALID_REQUEST,
334                "no active session; call session/new first",
335            ))
336            .await;
337            return;
338        }
339        let params: SessionPromptParams = params
340            .and_then(|p| serde_json::from_value(p).ok())
341            .unwrap_or_default();
342        let text = flatten_prompt(&params.prompt);
343        if text.is_empty() {
344            self.write(&JsonRpcMessage::error_response(
345                id,
346                error_codes::INVALID_PARAMS,
347                "prompt has no usable text content",
348            ))
349            .await;
350            return;
351        }
352        // Parse `---region:<name>---` markers; with none, the whole text is the
353        // `task` region (back-compat).
354        let regions = parse_region_markers(&text);
355        let task = regions.get("task").cloned().unwrap_or_default();
356        let stop_reason = self.run_turn(reader, task, regions).await;
357        self.write(&JsonRpcMessage::response(
358            id,
359            &SessionPromptResult { stop_reason },
360        ))
361        .await;
362    }
363
364    /// `session/cancel` notification arriving between turns: cancel the session's
365    /// run if one exists.
366    async fn on_cancel_notification(&mut self, params: Option<serde_json::Value>) {
367        let _: SessionCancelParams = params
368            .and_then(|p| serde_json::from_value(p).ok())
369            .unwrap_or_default();
370        if let Some(run_id) = self.session.as_ref().and_then(|s| s.run_id.clone()) {
371            let _ = self
372                .control
373                .request(&ControlRequest::Cancel { run_id })
374                .await;
375        }
376    }
377
378    /// Drive one prompt turn: spawn (or message) the agent, then translate the
379    /// daemon's events and the run's output until it goes terminal or parks.
380    async fn run_turn(
381        &mut self,
382        reader: &mut BoxReader,
383        task: String,
384        regions: std::collections::HashMap<String, String>,
385    ) -> StopReason {
386        // Subscribe before spawning so no event between spawn and subscribe is
387        // missed. An unreachable daemon ends the turn as a refusal.
388        let Ok(mut stream) = self.control.subscribe().await else {
389            return StopReason::Refusal;
390        };
391
392        let session_id = self
393            .session
394            .as_ref()
395            .expect("session present")
396            .session_id
397            .clone();
398        let run_id = match self.start_run(task, regions).await {
399            RunStart::Ready(run_id) => run_id,
400            // The agent already finished and won't take another message - the
401            // turn is simply over, not a failure.
402            RunStart::MessageUndeliverable => return StopReason::EndTurn,
403            // The daemon refused to create the run, or was unreachable.
404            RunStart::SpawnFailed => return StopReason::Refusal,
405        };
406
407        let mut tail = StageTail::new();
408        while self.io_alive {
409            tokio::select! {
410                biased;
411                event = stream.next() => {
412                    let Some(event) = event else {
413                        // The daemon closed the stream (restart); end the turn
414                        // with whatever output we have.
415                        self.flush_output(&session_id, &mut tail, &run_id).await;
416                        return StopReason::EndTurn;
417                    };
418                    if event.run_id() != run_id {
419                        continue; // another run in the shared world
420                    }
421                    self.flush_output(&session_id, &mut tail, &run_id).await;
422                    match event {
423                        WorldEvent::Completed { status, .. } => {
424                            return leviath_agent_client::stop_reason_for_label(&status);
425                        }
426                        WorldEvent::Context { total_tokens, max_tokens, .. } => {
427                            self.emit_usage(&session_id, total_tokens, max_tokens).await;
428                        }
429                        WorldEvent::Interaction { request, .. } => {
430                            match self.handle_interaction(reader, &session_id, &run_id, request).await {
431                                InteractionOutcome::Continue => {}
432                                InteractionOutcome::Park(reason) => return reason,
433                            }
434                        }
435                        // Status / Tokens / Spawned: the output flush above is all
436                        // that's needed.
437                        _ => {}
438                    }
439                    // A run can reach a done state without a `Completed` event -
440                    // `CompleteInteractive` stays live for follow-up, so it emits
441                    // no terminal event. Consult the persisted run status after
442                    // every event so the turn ends when (and only when) the run is
443                    // genuinely finished, never while it is merely `WaitingInput`
444                    // on an interaction Leviath is handling out of band.
445                    if let Some(reason) = self.run_finished(&run_id) {
446                        return reason;
447                    }
448                }
449                _ = tokio::time::sleep(OUTPUT_POLL) => {
450                    self.flush_output(&session_id, &mut tail, &run_id).await;
451                    if let Some(reason) = self.run_finished(&run_id) {
452                        return reason;
453                    }
454                }
455                incoming = read_line(reader) => {
456                    match incoming {
457                        // Stdin closed mid-turn: the client is gone.
458                        None => {
459                            self.flush_output(&session_id, &mut tail, &run_id).await;
460                            return StopReason::EndTurn;
461                        }
462                        Some(line) => self.handle_midturn_input(&run_id, &line).await,
463                    }
464                }
465            }
466        }
467        // The output stream broke mid-turn; the client is gone.
468        StopReason::EndTurn
469    }
470
471    /// Whether the run has reached a state that should end the current turn,
472    /// read from its persisted `meta.json` status. Returns the stop reason to
473    /// report, or `None` while the run is still starting / running / blocked on
474    /// input (`WaitingInput`) - the latter must keep the turn in flight so a
475    /// non-interactive client is never told "done" while the agent is actually
476    /// waiting on an interaction Leviath is handling out of band.
477    fn run_finished(&self, run_id: &str) -> Option<StopReason> {
478        let status = read_run_status(&self.runs_dir, run_id)?;
479        leviath_agent_client::stop_reason_for(&status)
480    }
481
482    /// Spawn the agent on the first prompt, or deliver a message on later ones.
483    /// `regions` seeds named caller-input regions on the first (spawning) prompt;
484    /// on later prompts the text is delivered as a message and `regions` is unused.
485    async fn start_run(
486        &mut self,
487        task: String,
488        regions: std::collections::HashMap<String, String>,
489    ) -> RunStart {
490        let existing = self
491            .session
492            .as_ref()
493            .expect("session present")
494            .run_id
495            .clone();
496        match existing {
497            Some(run_id) => {
498                let delivered = matches!(
499                    self.control
500                        .request(&ControlRequest::Message {
501                            agent_id: run_id.clone(),
502                            content: task,
503                            target_region: None,
504                        })
505                        .await,
506                    Ok(ControlResponse::Ok { ok: true })
507                );
508                if delivered {
509                    RunStart::Ready(run_id)
510                } else {
511                    RunStart::MessageUndeliverable
512                }
513            }
514            None => {
515                let session = self.session.as_ref().expect("session present");
516                let spawn =
517                    spawn_args(&session.blueprint, &task, &session.cwd, &self.args, regions);
518                match self.control.spawn(spawn).await {
519                    Ok(ControlResponse::Spawned { run_id }) => {
520                        self.session.as_mut().expect("session present").run_id =
521                            Some(run_id.clone());
522                        RunStart::Ready(run_id)
523                    }
524                    _ => RunStart::SpawnFailed,
525                }
526            }
527        }
528    }
529
530    /// Handle an interaction raised while a turn is streaming.
531    ///
532    /// The strategy depends on whether the client advertised capabilities at
533    /// `initialize` (i.e. whether it implements the client-side protocol methods
534    /// and can answer an agent-initiated request):
535    ///
536    /// - **Capable client + tool approval** → drive it over
537    ///   `session/request_permission`, answered in-turn; the run continues.
538    /// - **Capable client + any other interaction** → surface the question as
539    ///   output and end the turn (`Park`). The client owns the conversation and
540    ///   re-prompts with the answer, which arrives as the next `session/prompt` -
541    ///   the standard Agent Client Protocol turn boundary.
542    /// - **Client without capabilities (e.g. Gas City, which reports interaction
543    ///   unsupported)** → surface the question as output and **keep the turn in
544    ///   flight** (`Continue`). The run is genuinely blocked, so the turn must not
545    ///   report "done"; the human resolves it through Leviath's own surfaces
546    ///   (`lev dash` / `lev respond`) and the run then continues to completion.
547    async fn handle_interaction(
548        &mut self,
549        reader: &mut BoxReader,
550        session_id: &str,
551        run_id: &str,
552        request: InteractionRequest,
553    ) -> InteractionOutcome {
554        if self.caps_present {
555            if is_permission_request(&request) {
556                return self
557                    .request_permission(reader, session_id, run_id, request)
558                    .await;
559            }
560            // A capable client drives its own conversation: surface the question
561            // and hand control back so it can re-prompt with the answer.
562            self.emit_chunk(session_id, &format!("{}\n", request.prompt))
563                .await;
564            return InteractionOutcome::Park(StopReason::EndTurn);
565        }
566        // A client that cannot answer interactions: surface the question and keep
567        // the turn alive. Leviath handles the interaction out of band; the turn
568        // ends only when the run itself reaches a done state.
569        self.emit_chunk(session_id, &format!("{}\n", request.prompt))
570            .await;
571        InteractionOutcome::Continue
572    }
573
574    /// Ask the host to approve a tool call and relay the decision to the daemon.
575    async fn request_permission(
576        &mut self,
577        reader: &mut BoxReader,
578        session_id: &str,
579        run_id: &str,
580        request: InteractionRequest,
581    ) -> InteractionOutcome {
582        let request_id = serde_json::json!(self.next_id());
583        let params = permission_request(session_id, &request);
584        self.write(&JsonRpcMessage::request(
585            request_id.clone(),
586            "session/request_permission",
587            &params,
588        ))
589        .await;
590
591        // Await the matching response. Other inbound messages during the wait are
592        // ignored except a cancel, which rejects the call and cancels the run.
593        loop {
594            let Some(line) = read_line(reader).await else {
595                return InteractionOutcome::Park(StopReason::EndTurn);
596            };
597            let Ok(msg) = serde_json::from_str::<JsonRpcMessage>(line.trim()) else {
598                continue;
599            };
600            if msg.method.as_deref() == Some("session/cancel") {
601                let _ = self
602                    .control
603                    .request(&ControlRequest::Cancel {
604                        run_id: run_id.to_string(),
605                    })
606                    .await;
607                self.answer_interaction(&request.id, false, ApprovalScope::Once)
608                    .await;
609                return InteractionOutcome::Continue;
610            }
611            if msg.id.as_ref() == Some(&request_id) {
612                let choice = msg
613                    .result
614                    .and_then(|r| serde_json::from_value::<RequestPermissionResult>(r).ok())
615                    .map(|r| interpret_permission(&r.outcome))
616                    .unwrap_or(PermissionChoice {
617                        approved: false,
618                        scope: ApprovalScope::Once,
619                    });
620                self.answer_interaction(&request.id, choice.approved, choice.scope)
621                    .await;
622                return InteractionOutcome::Continue;
623            }
624            // Unrelated message; keep waiting.
625        }
626    }
627
628    /// Relay an approval decision to the daemon's interaction hub.
629    ///
630    /// Takes `&mut self` (though it only reads `self.control`) so the future
631    /// stays `Send`: a shared `&Server` would require `Server: Sync`, which the
632    /// erased `dyn AsyncWrite` writer is not.
633    async fn answer_interaction(&mut self, request_id: &str, approved: bool, scope: ApprovalScope) {
634        let response = InteractionResponse {
635            request_id: request_id.to_string(),
636            value: None,
637            choice_index: None,
638            approved: Some(approved),
639            scope: Some(scope),
640        };
641        let _ = self
642            .control
643            .request(&ControlRequest::AnswerInteraction { response })
644            .await;
645    }
646
647    /// A message received while a turn is in flight. Only `session/cancel` (for
648    /// this run) is actionable; everything else is ignored.
649    async fn handle_midturn_input(&mut self, run_id: &str, line: &str) {
650        let Ok(msg) = serde_json::from_str::<JsonRpcMessage>(line.trim()) else {
651            return;
652        };
653        if msg.method.as_deref() == Some("session/cancel") {
654            let _ = self
655                .control
656                .request(&ControlRequest::Cancel {
657                    run_id: run_id.to_string(),
658                })
659                .await;
660        }
661    }
662
663    /// Flush any newly-written run output as `agent_message_chunk` notifications,
664    /// split into host-safe frames.
665    async fn flush_output(&mut self, session_id: &str, tail: &mut StageTail, run_id: &str) {
666        let text = tail.pump(&self.runs_dir, run_id);
667        for chunk in split_chunks(&text) {
668            self.emit_chunk(session_id, chunk).await;
669        }
670    }
671
672    /// Emit one `agent_message_chunk` update.
673    async fn emit_chunk(&mut self, session_id: &str, text: &str) {
674        let params = SessionUpdateParams {
675            session_id: session_id.to_string(),
676            update: SessionUpdate::AgentMessageChunk {
677                content: ContentBlock::text(text),
678            },
679        };
680        self.write(&JsonRpcMessage::notification("session/update", &params))
681            .await;
682    }
683
684    /// Emit one `usage_update` update.
685    async fn emit_usage(&mut self, session_id: &str, used: usize, size: usize) {
686        let params = SessionUpdateParams {
687            session_id: session_id.to_string(),
688            update: SessionUpdate::UsageUpdate { used, size },
689        };
690        self.write(&JsonRpcMessage::notification("session/update", &params))
691            .await;
692    }
693
694    /// Serialize one message as a single line and flush it. Output is
695    /// best-effort: on any write error the client is assumed gone and
696    /// [`Server::io_alive`] flips to `false`, winding the server down.
697    async fn write(&mut self, msg: &JsonRpcMessage) {
698        let mut line = serde_json::to_string(msg).expect("JsonRpcMessage always serializes");
699        line.push('\n');
700        let ok = self.writer.write_all(line.as_bytes()).await.is_ok()
701            && self.writer.flush().await.is_ok();
702        if !ok {
703            self.io_alive = false;
704        }
705    }
706
707    /// Next agent→client request id.
708    fn next_id(&mut self) -> i64 {
709        self.next_request_id += 1;
710        self.next_request_id
711    }
712}
713
714/// Read the persisted `RunStatus` for `run_id` from `<runs_dir>/<run_id>/meta.json`.
715///
716/// Deserializes into a minimal projection that reads only the `status` field, so
717/// it does not depend on the full [`RunMeta`](leviath_core::run_meta::RunMeta)
718/// shape and tolerates a partially-written or older metadata file. Returns
719/// `None` if the file is missing or unreadable (the run hasn't persisted yet).
720fn read_run_status(runs_dir: &std::path::Path, run_id: &str) -> Option<RunStatus> {
721    #[derive(serde::Deserialize)]
722    struct StatusOnly {
723        status: RunStatus,
724    }
725    let path = runs_dir.join(run_id).join("meta.json");
726    let json = std::fs::read_to_string(path).ok()?;
727    serde_json::from_str::<StatusOnly>(&json)
728        .ok()
729        .map(|s| s.status)
730}
731
732/// The stop reason to report for a run in `status`, or `None` if the run has not
733/// finished and the turn should keep streaming.
734///
735/// `CompleteInteractive` counts as finished: the agent completed its required
736/// work and is only idling for optional follow-up, so control returns to the
737/// client. `WaitingInput` does **not** - the agent is blocked on an interaction,
738/// which is exactly the state that must not be reported as "done".
739/// Mint a session id from the agent name - reuses the run-id generator's
740/// collision-resistant `<name>-<timestamp>-<suffix>` scheme.
741fn new_session_id(agent_name: &str) -> String {
742    crate::runstate::new_run_id(agent_name)
743}
744
745/// Read one newline-terminated line, or `None` at end of stream.
746///
747/// A read error is treated the same as EOF (`None`): either way the client is no
748/// longer sending, and there is nothing useful to do but wind down. Collapsing
749/// the error into `None` via `unwrap_or(0)` also keeps this branch-free for the
750/// coverage gate.
751async fn read_line(reader: &mut BoxReader) -> Option<String> {
752    let mut line = String::new();
753    match reader.read_line(&mut line).await.unwrap_or(0) {
754        0 => None,
755        _ => Some(line),
756    }
757}
758
759/// Small, pure translations for the prompt loop, kept apart so they unit-test in
760/// isolation from the async machinery.
761mod mapping {
762    use leviath_agent_client::PermissionOutcome;
763    use leviath_agent_client::mapping::{
764        OPTION_ALLOW_ALWAYS, OPTION_ALLOW_ONCE, OPTION_REJECT_ONCE,
765    };
766    use leviath_core::interaction::ApprovalScope;
767
768    /// A resolved permission decision to relay to the daemon.
769    pub(super) struct PermissionChoice {
770        /// Whether the tool call is approved.
771        pub(super) approved: bool,
772        /// The scope of the decision.
773        pub(super) scope: ApprovalScope,
774    }
775
776    /// Interpret a host's permission outcome into an approve/deny + scope.
777    ///
778    /// The three option ids we offer map to allow-once, allow-for-session, and
779    /// reject. A `cancelled` outcome, or any option id we did not offer, is
780    /// treated as a one-time rejection - the safe default.
781    pub(super) fn interpret_permission(outcome: &PermissionOutcome) -> PermissionChoice {
782        match outcome {
783            PermissionOutcome::Selected { option_id } if option_id == OPTION_ALLOW_ONCE => {
784                PermissionChoice {
785                    approved: true,
786                    scope: ApprovalScope::Once,
787                }
788            }
789            PermissionOutcome::Selected { option_id } if option_id == OPTION_ALLOW_ALWAYS => {
790                PermissionChoice {
791                    approved: true,
792                    scope: ApprovalScope::Session,
793                }
794            }
795            PermissionOutcome::Selected { option_id } if option_id == OPTION_REJECT_ONCE => {
796                PermissionChoice {
797                    approved: false,
798                    scope: ApprovalScope::Once,
799                }
800            }
801            _ => PermissionChoice {
802                approved: false,
803                scope: ApprovalScope::Once,
804            },
805        }
806    }
807
808    /// The stop reason to report for a run whose `WorldEvent::Completed` carried
809    /// `status`. The host emits only the terminal statuses `complete`, `error`,
810    /// and `cancelled`.
811    #[cfg(test)]
812    mod tests {
813        use super::*;
814
815        #[test]
816        fn interpret_maps_every_offered_option() {
817            let allow_once = interpret_permission(&PermissionOutcome::Selected {
818                option_id: OPTION_ALLOW_ONCE.to_string(),
819            });
820            assert!(allow_once.approved);
821            assert_eq!(allow_once.scope, ApprovalScope::Once);
822
823            let allow_always = interpret_permission(&PermissionOutcome::Selected {
824                option_id: OPTION_ALLOW_ALWAYS.to_string(),
825            });
826            assert!(allow_always.approved);
827            assert_eq!(allow_always.scope, ApprovalScope::Session);
828
829            let reject = interpret_permission(&PermissionOutcome::Selected {
830                option_id: OPTION_REJECT_ONCE.to_string(),
831            });
832            assert!(!reject.approved);
833            assert_eq!(reject.scope, ApprovalScope::Once);
834        }
835
836        #[test]
837        fn interpret_denies_unknown_option_and_cancellation() {
838            let unknown = interpret_permission(&PermissionOutcome::Selected {
839                option_id: "made-up".to_string(),
840            });
841            assert!(!unknown.approved);
842            assert_eq!(unknown.scope, ApprovalScope::Once);
843
844            let cancelled = interpret_permission(&PermissionOutcome::Cancelled);
845            assert!(!cancelled.approved);
846            assert_eq!(cancelled.scope, ApprovalScope::Once);
847        }
848    }
849}
850
851#[cfg(test)]
852mod tests;