Skip to main content

kranz_engine/
backend_acp.rs

1//! ACP (Agent Client Protocol) agent backend: spawns and supervises an
2//! external agent process speaking JSON-RPC 2.0 over NDJSON stdio (KRZ-301 —
3//! the vendor-neutrality seam: any agent that speaks ACP can drive a kranz
4//! mission role without a per-vendor CLI parser).
5//!
6//! **Schema version targeted:** ACP protocol version **1** — the stable
7//! `schema/v1/schema.json` of `agentclientprotocol/agent-client-protocol`
8//! (meta.json `"version": 1`, crate release v1.6.0, 2026-07-21). v2 shapes
9//! (schema.unstable.json) are deliberately NOT used: unstable discriminators
10//! would tie the seam to a moving target. Protocol versions are MAJOR-only,
11//! so a peer answering `initialize` with anything other than 1 is rejected.
12//!
13//! ## Wire → event mapping
14//!
15//! | ACP message | maps to |
16//! |---|---|
17//! | `initialize` + `session/new` responses | synthesized [`AgentEvent::Init`] (the peer's `sessionId`; model recorded from config — ACP v1 has no standard model-reporting field) |
18//! | `session/update` `agent_message_chunk` (text) | [`AgentEvent::Text`] per chunk (deltas) |
19//! | `session/update` `tool_call` | [`AgentEvent::ToolUse`] (`tool` = ACP `kind`, `summary` = `title`) |
20//! | `session/update` `tool_call_update` with terminal status (`completed`/`failed`) | [`AgentEvent::ToolResult`] (`denied: false` — a failed tool is a normal failure, not a denial, mirroring `backend_codex`) |
21//! | `session/request_permission` refused at the seam | synthesized [`AgentEvent::ToolResult`] with `denied: true` (the refusal IS the event — the peer may report nothing itself) |
22//! | `session/update` `usage_update` | remembered; its `cost` (USD only) lands on the terminal `Result.cost_usd` |
23//! | `session/prompt` response (`stopReason`) | synthesized terminal [`AgentEvent::Result`]: text stitched from the LAST assistant message's chunks (mirrors `backend_codex` "last agent_message wins"; `messageId` changes delimit messages), `is_error` ⇔ `stopReason != "end_turn"` — ACP v1 names `end_turn` as the ONLY natural completion, so `refusal`, `max_tokens`, `max_turn_requests`, `cancelled`, and any missing/unknown reason all fail honestly (12th-pass review: a truncated or cancelled validator report must never read as a pass) |
24//! | everything else (`plan`, `agent_thought_chunk`, `available_commands_update`, unknown kinds, unparseable lines) | [`AgentEvent::Other`] — kept for transcripts, never dropped |
25//!
26//! ## What is NOT on this wire (absent, never fabricated)
27//!
28//! ACP v1's `usage_update` reports context-window state (`used`/`size`
29//! tokens) and an optional cumulative `cost` — NOT an input/output/cache
30//! token split. [`AgentEvent::Result`] `usage` therefore stays the zero
31//! default (a fabricated split would be invented data), and `cost_usd` is
32//! `Some` only when the peer reported a USD amount. There is likewise no
33//! client-side price-table fallback: kranz cannot price an arbitrary ACP
34//! peer's model, so unreported cost stays `None`. Model selection is the
35//! peer's own concern (encoded in the configured command/args); the
36//! configured model string is recorded on `Init` for attribution only.
37//!
38//! ## Permission mapping (the allow/deny seam)
39//!
40//! The peer asks before acting via `session/request_permission`; the answer
41//! is computed from the [`SessionSpec`] (see [`decide_permission`]):
42//!
43//! - **Disallowed patterns** (`spec.disallowed_tools`, claude-shaped strings
44//!   like `Bash(git push*)`) are matched against the ACP `kind` via
45//!   [`TOOL_NAME_KINDS`] (`Bash`↔`execute`, `Edit`/`Write`↔`edit`, …) and a
46//!   `*`-wildcard glob against the call's subject (command for `execute`,
47//!   path for the file kinds, title otherwise). A match refuses the call —
48//!   this is where the no-push/no-publish invariants land.
49//! - **Read-only sessions** (`writable: false`) refuse the filesystem-
50//!   mutating kinds `edit`/`delete`/`move` outright; `execute` stays allowed
51//!   unless disallowed-matched, because a read-only role still runs
52//!   read-only commands (`git diff`, `cargo test`) and ACP's `execute` kind
53//!   does not split reads from writes. This is the ACP equivalent posture;
54//!   what it CANNOT do is constrain a peer that never asks permission —
55//!   there is no client-side fs proxy in v1, and `BackendKind::Acp` reports
56//!   `supports_sandbox_enforcement() == false`, so `config::validate`
57//!   refuses an enforced OS sandbox on this backend rather than letting the
58//!   gap go silent. `spec.allowed_tools` is not interpreted yet (the peer's
59//!   permission request IS the ask; auto-approving without one would weaken
60//!   the seam).
61//!
62//! - **Missing wire fields fail CLOSED.** ACP v1 leaves `title` optional and
63//!   `rawInput` free-form, so a call can arrive with no subject at all; every
64//!   glob then matches nothing and the deny list would silently vacate. When
65//!   a deny pattern's tool name covers the call's kind but the subject is
66//!   empty, the call is refused and the reason names the missing field. Same
67//!   for a read-only session when the peer omits `kind` (it arrives as
68//!   `"other"`, which `MUTATING_KINDS` cannot classify).
69//!
70//! A refusal picks the first `reject_once` (else `reject_always`) option the
71//! peer offered, falling back to the `cancelled` outcome when it offered
72//! none; an approval picks the first `allow_once` (else `allow_always`,
73//! else first) option.
74//!
75//! ## Process supervision
76//!
77//! House discipline, mirroring `backend_kimi`/`backend_codex`: env-cleared
78//! spawn ([`crate::agent_env::agent_session_env`] — ACP has no canonical
79//! auth env var, so NO ambient credential crosses; the peer authenticates
80//! from its own config), unix process-group + post-reap sweep /
81//! windows Job Object tree kill, `kill_on_drop`, bounded stdout lines and a
82//! bounded stderr tail ([`crate::stream_bounds`]). A killed peer's torn
83//! final NDJSON line is never a parse failure: [`BoundedLines`] returns it
84//! as one last unterminated line, which routes to [`AgentEvent::Other`]
85//! like any unparseable line, so the events already surfaced stay complete
86//! and the session simply ends `Aborted`/`Failed`.
87//!
88//! `resume` is rejected at the seam (`session/load` is an optional v1
89//! capability this backend does not negotiate; `session/resume` is
90//! unstable-v2 only). Client capabilities advertise `fs`/`terminal` as
91//! unsupported, so a conformant peer never calls `fs/*`/`terminal/*`; one
92//! that does gets a JSON-RPC `-32601` error response, not silent service.
93
94use crate::backend::{
95    AgentBackend, AgentEvent, AgentSession, PromptMode, SessionExit, SessionSpec,
96};
97#[cfg(unix)]
98use crate::backend_claude::kill_group;
99#[cfg(windows)]
100use crate::backend_claude::win_job;
101use crate::error::{EngineError, Result};
102use crate::stream_bounds::{drain_to_tail, BoundedLines, STDERR_TAIL_CAP};
103use crate::types::TokenUsage;
104use serde_json::{json, Value};
105use std::collections::{HashMap, VecDeque};
106use std::path::PathBuf;
107use std::process::Stdio;
108use std::sync::{Arc, Mutex};
109use tokio::process::{Child, ChildStdin, ChildStdout};
110use tokio::task::JoinHandle;
111
112/// Max characters kept in tool-use / tool-result summaries.
113const SUMMARY_MAX_CHARS: usize = 200;
114/// Max characters of captured stderr included in failure messages.
115const STDERR_TAIL_CHARS: usize = 500;
116
117/// The only ACP protocol version this backend speaks (stable schema v1; see
118/// module docs). A peer negotiating anything else is refused at `initialize`.
119const ACP_PROTOCOL_VERSION: u64 = 1;
120
121/// Deadline for one handshake request (`initialize`, `session/new`). Both
122/// are capability negotiation — no model call — so a peer that cannot answer
123/// within this window is hung or not an ACP agent; bounding it keeps
124/// `start()` from parking the run loop forever. The prompt turn itself is
125/// deliberately unbounded here: turn/stall budgets are the engine's call
126/// (runner-level), not the transport's.
127const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
128
129/// JSON-RPC method names, stable schema v1 (`meta.json`).
130mod method {
131    pub(crate) const INITIALIZE: &str = "initialize";
132    pub(crate) const SESSION_NEW: &str = "session/new";
133    pub(crate) const SESSION_PROMPT: &str = "session/prompt";
134    pub(crate) const SESSION_CANCEL: &str = "session/cancel";
135    pub(crate) const SESSION_UPDATE: &str = "session/update";
136    pub(crate) const REQUEST_PERMISSION: &str = "session/request_permission";
137}
138
139/// Claude-style tool name → ACP `kind`s it matches, for interpreting
140/// `SessionSpec::disallowed_tools` patterns against ACP tool calls. ACP
141/// kinds are the only tool identity the wire carries (`title` is
142/// free-form display text), so the mapping is necessarily coarse; it is
143/// used ONLY for deny decisions, never to auto-approve.
144const TOOL_NAME_KINDS: &[(&str, &[&str])] = &[
145    ("Bash", &["execute"]),
146    ("Read", &["read"]),
147    ("Write", &["edit"]),
148    ("Edit", &["edit"]),
149    ("NotebookEdit", &["edit"]),
150    ("Glob", &["search"]),
151    ("Grep", &["search"]),
152    ("WebSearch", &["search", "fetch"]),
153    ("WebFetch", &["fetch"]),
154];
155
156/// ACP `kind`s that mutate the filesystem; refused outright in read-only
157/// (`writable: false`) sessions. `execute` is deliberately not in this set —
158/// see the module-docs permission section.
159const MUTATING_KINDS: &[&str] = &["edit", "delete", "move"];
160
161// ---------------------------------------------------------------------------
162// JSON-RPC framing
163// ---------------------------------------------------------------------------
164
165/// One stdout line classified by JSON-RPC shape. The wire is symmetric
166/// (both sides issue requests), so a line is one of: a response to a client
167/// request, a peer request we must answer, or a notification.
168#[derive(Debug)]
169enum Frame {
170    /// `result`/`error` for a client-issued request id.
171    Response { id: u64, outcome: RpcOutcome },
172    /// Peer→client request (`session/request_permission`, or an unsupported
173    /// client method). The `id` is echoed back verbatim — it may be a string
174    /// or a number per JSON-RPC, so it is kept as a raw [`Value`].
175    Request {
176        id: Value,
177        method: String,
178        params: Value,
179        raw: Value,
180    },
181    /// Peer→client notification (`session/update`, or anything else).
182    Notification {
183        method: String,
184        params: Value,
185        raw: Value,
186    },
187    /// Not JSON-RPC-shaped (or not JSON at all) — transcript only.
188    Unrecognized(Value),
189}
190
191/// The payload of a JSON-RPC response: peer ids are always numbers in our
192/// exchanges with the peer's client side, but the error path keeps the raw
193/// object for the transcript.
194#[derive(Debug)]
195enum RpcOutcome {
196    Result(Value),
197    Error(Value),
198}
199
200/// Classify one stdout line. Unparseable lines become
201/// [`Frame::Unrecognized`] with `raw = {"unparsed": <line>}` so nothing is
202/// ever dropped from transcripts (mirrors `backend_codex::parse_codex_line`).
203fn classify_line(line: &str) -> Frame {
204    let value = match serde_json::from_str::<Value>(line) {
205        Ok(value) => value,
206        Err(_) => return Frame::Unrecognized(json!({ "unparsed": line })),
207    };
208    classify_value(value)
209}
210
211fn classify_value(value: Value) -> Frame {
212    let obj = match value.as_object() {
213        Some(obj) => obj,
214        None => return Frame::Unrecognized(value),
215    };
216    let has_id = obj.contains_key("id");
217    let method = obj.get("method").and_then(Value::as_str);
218    match (method, has_id) {
219        // Request: method + id.
220        (Some(method), true) => Frame::Request {
221            id: obj.get("id").cloned().unwrap_or(Value::Null),
222            method: method.to_string(),
223            params: obj.get("params").cloned().unwrap_or(Value::Null),
224            raw: value,
225        },
226        // Notification: method, no id.
227        (Some(method), false) => Frame::Notification {
228            method: method.to_string(),
229            params: obj.get("params").cloned().unwrap_or(Value::Null),
230            raw: value,
231        },
232        // Response: no method, carries result or error. Our request ids are
233        // numbers; anything else is not a response to us.
234        (None, true) => {
235            let id = obj.get("id").and_then(Value::as_u64);
236            match (id, obj.get("result"), obj.get("error")) {
237                (Some(id), Some(result), _) => Frame::Response {
238                    id,
239                    outcome: RpcOutcome::Result(result.clone()),
240                },
241                (Some(id), None, Some(error)) => Frame::Response {
242                    id,
243                    outcome: RpcOutcome::Error(error.clone()),
244                },
245                _ => Frame::Unrecognized(value),
246            }
247        }
248        (None, false) => Frame::Unrecognized(value),
249    }
250}
251
252// ---------------------------------------------------------------------------
253// Permission policy (pure; the seam the ticket pins)
254// ---------------------------------------------------------------------------
255
256/// What the seam decided about one `session/request_permission`.
257#[derive(Debug, Clone, PartialEq, Eq)]
258enum PermissionDecision {
259    Allow,
260    /// Human-readable reason; lands on the synthesized denial event's summary.
261    Deny(String),
262}
263
264/// Everything known about the tool call a permission request covers: the
265/// `tool_call`/`tool_call_update` fields tracked by [`AcpSession`], merged
266/// with whatever the request itself carries (the request's embedded
267/// `ToolCallUpdate` may be the first place a title/kind appears).
268#[derive(Debug, Clone, Default)]
269struct ToolCallInfo {
270    kind: String,
271    title: String,
272    /// Command (`execute`) or path (file kinds) extracted from
273    /// `rawInput`/`locations`; the subject glob patterns match against.
274    subject: String,
275}
276
277/// Extract the matchable subject from a tool-call-shaped value
278/// (`rawInput.command`/`cmd` for execute, `locations[0].path` or
279/// `rawInput.path` for the file kinds, `title` as the last resort).
280fn tool_call_subject(kind: &str, title: &str, call: &Value) -> String {
281    let raw_input = call.get("rawInput").cloned().unwrap_or(Value::Null);
282    let str_at = |value: &Value, keys: &[&str]| -> Option<String> {
283        keys.iter()
284            .find_map(|k| value.get(*k).and_then(Value::as_str).map(str::to_string))
285    };
286    if kind == "execute" {
287        if let Some(command) = str_at(&raw_input, &["command", "cmd"]) {
288            return command;
289        }
290    }
291    if let Some(path) = call
292        .get("locations")
293        .and_then(Value::as_array)
294        .and_then(|locs| locs.first())
295        .and_then(|loc| loc.get("path"))
296        .and_then(Value::as_str)
297    {
298        return path.to_string();
299    }
300    if let Some(path) = str_at(&raw_input, &["path", "filePath", "file_path"]) {
301        return path;
302    }
303    title.to_string()
304}
305
306/// `*`-wildcard match (a bare `*` spans any text including empty; every
307/// other character matches literally, case-sensitively — shell commands and
308/// paths are case-sensitive on the platforms this guards).
309fn wildcard_match(pattern: &str, text: &str) -> bool {
310    let parts = pattern.split('*');
311    let anchored_start = !pattern.starts_with('*');
312    let anchored_end = !pattern.ends_with('*');
313    let mut rest = text;
314    let mut first = true;
315    for part in parts {
316        if part.is_empty() {
317            first = false;
318            continue;
319        }
320        match rest.find(part) {
321            Some(idx) if !first || !anchored_start || idx == 0 => {
322                rest = &rest[idx + part.len()..];
323            }
324            _ => return false,
325        }
326        first = false;
327    }
328    // After the last literal, a pattern not ending in `*` must end exactly
329    // there (nothing left over).
330    !anchored_end || rest.is_empty()
331}
332
333/// Split one claude-shaped permission pattern (`Bash(git push*)`, `Write`,
334/// …) into its tool name and subject glob. A bare name carries the glob `*`.
335fn split_pattern(pattern: &str) -> (&str, &str) {
336    match pattern.split_once('(') {
337        Some((name, rest)) => (name.trim(), rest.strip_suffix(')').unwrap_or(rest)),
338        None => (pattern.trim(), "*"),
339    }
340}
341
342/// Whether one permission pattern's tool name maps to this ACP `kind` at
343/// all, regardless of subject. Separate from [`pattern_matches`] because a
344/// pattern that COVERS a call but cannot be evaluated against it is a
345/// refusal, not a pass.
346fn pattern_covers_kind(pattern: &str, kind: &str) -> bool {
347    let (name, _) = split_pattern(pattern);
348    TOOL_NAME_KINDS
349        .iter()
350        .find(|(n, _)| n.eq_ignore_ascii_case(name))
351        .is_some_and(|(_, kinds)| kinds.contains(&kind))
352}
353
354/// Whether one claude-shaped permission pattern (`Bash(git push*)`,
355/// `Write`, …) covers an ACP tool call of `kind` with `subject`.
356fn pattern_matches(pattern: &str, kind: &str, subject: &str) -> bool {
357    let (_, glob) = split_pattern(pattern);
358    if pattern_covers_kind(pattern, kind) {
359        wildcard_match(glob, subject)
360    } else {
361        false
362    }
363}
364
365/// The seam: decide one permission request from the [`SessionSpec`]. Deny
366/// rules are evaluated before the read-only posture so the recorded reason
367/// names the most specific rule that fired.
368///
369/// Fails CLOSED on missing wire fields. ACP v1 leaves `title` optional and
370/// `rawInput` free-form, so a peer can send a tool call with no subject at
371/// all; every glob then matches nothing and the deny list silently vacates.
372/// The same holds for the read-only posture when the peer omits `kind`
373/// (it arrives as `"other"`, which `MUTATING_KINDS` cannot classify). In
374/// both cases the guard cannot be evaluated, so the call is refused and the
375/// reason names the field the peer left out.
376fn decide_permission(spec: &SessionSpec, call: &ToolCallInfo) -> PermissionDecision {
377    if call.subject.trim().is_empty() {
378        if let Some(pattern) = spec
379            .disallowed_tools
380            .iter()
381            .find(|pattern| pattern_covers_kind(pattern, &call.kind))
382        {
383            return PermissionDecision::Deny(format!(
384                "tool call carries no subject (no rawInput command or path, no locations[0].path, \
385                 no title), so deny pattern {pattern:?} for ACP kind {:?} cannot be evaluated",
386                call.kind
387            ));
388        }
389    }
390    for pattern in &spec.disallowed_tools {
391        if pattern_matches(pattern, &call.kind, &call.subject) {
392            return PermissionDecision::Deny(format!(
393                "matches SessionSpec.disallowed_tools pattern {pattern:?}"
394            ));
395        }
396    }
397    if !spec.writable {
398        let kind = call.kind.trim();
399        if kind.is_empty() || kind == "other" {
400            return PermissionDecision::Deny(format!(
401                "read-only session (writable: false): tool call carries no usable ACP kind \
402                 ({kind:?}), so whether it mutates the filesystem cannot be decided"
403            ));
404        }
405        if MUTATING_KINDS.contains(&kind) {
406            return PermissionDecision::Deny(format!(
407                "read-only session (writable: false): ACP kind {:?} mutates the filesystem",
408                call.kind
409            ));
410        }
411    }
412    PermissionDecision::Allow
413}
414
415/// Build the JSON-RPC result answering a permission request. `Allow` picks
416/// the first `allow_once` option, then `allow_always`, then the first
417/// option at all; `Deny` picks the first `reject_once`, then
418/// `reject_always`, and falls back to the `cancelled` outcome when the peer
419/// offered no reject option (the only refusal the stable schema guarantees).
420fn permission_response(decision: &PermissionDecision, options: &[Value]) -> Value {
421    let option_kind = |opt: &Value| -> String {
422        opt.get("kind")
423            .and_then(Value::as_str)
424            .unwrap_or("")
425            .to_string()
426    };
427    let option_id = |opt: &Value| opt.get("optionId").cloned().unwrap_or(Value::Null);
428    let pick = |kinds: &[&str]| -> Option<Value> {
429        options
430            .iter()
431            .find(|opt| kinds.contains(&option_kind(opt).as_str()))
432            .map(option_id)
433    };
434    let selected = match decision {
435        PermissionDecision::Allow => pick(&["allow_once"])
436            .or_else(|| pick(&["allow_always"]))
437            .or_else(|| options.first().map(option_id)),
438        PermissionDecision::Deny(_) => pick(&["reject_once"]).or_else(|| pick(&["reject_always"])),
439    };
440    match selected {
441        Some(option_id) => json!({ "outcome": { "outcome": "selected", "optionId": option_id } }),
442        None => match decision {
443            PermissionDecision::Allow => {
444                // No allow option at all: the only safe answer is refusal.
445                json!({ "outcome": { "outcome": "cancelled" } })
446            }
447            PermissionDecision::Deny(_) => json!({ "outcome": { "outcome": "cancelled" } }),
448        },
449    }
450}
451
452// ---------------------------------------------------------------------------
453// Event mapping
454// ---------------------------------------------------------------------------
455
456/// Keep at most `max` characters (not bytes — never splits a code point).
457fn truncate_chars(text: &str, max: usize) -> String {
458    if text.chars().count() <= max {
459        text.to_string()
460    } else {
461        text.chars().take(max).collect()
462    }
463}
464
465/// Last `max` characters of `text` (for stderr tails in failure messages).
466fn last_chars(text: &str, max: usize) -> String {
467    let chars: Vec<char> = text.chars().collect();
468    let start = chars.len().saturating_sub(max);
469    chars[start..].iter().collect()
470}
471
472/// Summarize a tool call's content for a [`AgentEvent::ToolResult`]: the
473/// first text content block, else the updated title, else the status.
474fn tool_result_summary(update: &Value, tracked: &ToolCallInfo, status: &str) -> String {
475    if let Some(content) = update.get("content").and_then(Value::as_array) {
476        for item in content {
477            if item.get("type").and_then(Value::as_str) == Some("content") {
478                if let Some(text) = item
479                    .get("content")
480                    .and_then(|c| c.get("text"))
481                    .and_then(Value::as_str)
482                {
483                    return truncate_chars(text, SUMMARY_MAX_CHARS);
484                }
485            }
486        }
487    }
488    if !tracked.title.is_empty() {
489        return truncate_chars(&tracked.title, SUMMARY_MAX_CHARS);
490    }
491    status.to_string()
492}
493
494// ---------------------------------------------------------------------------
495// Backend
496// ---------------------------------------------------------------------------
497
498/// The [`AgentBackend`] for an ACP-speaking agent executable (KRZ-301).
499///
500/// There is no canonical ACP binary name and no `--version` convention, so
501/// there is deliberately no discovery/probe: the configured command is
502/// spawned directly and the `initialize` handshake IS the probe — a
503/// non-ACP executable fails there, loudly, before any prompt turn.
504#[derive(Debug, Clone)]
505pub struct AcpBackend {
506    program: PathBuf,
507    args: Vec<String>,
508}
509
510impl AcpBackend {
511    /// Spawn `program args` as the ACP agent (no validation performed; the
512    /// handshake at session start is the validation).
513    pub fn new(program: impl Into<PathBuf>, args: Vec<String>) -> Self {
514        AcpBackend {
515            program: program.into(),
516            args,
517        }
518    }
519
520    /// The program this backend spawns.
521    pub fn program(&self) -> &std::path::Path {
522        &self.program
523    }
524}
525
526#[async_trait::async_trait]
527impl AgentBackend for AcpBackend {
528    async fn start(&self, spec: SessionSpec) -> Result<Box<dyn AgentSession>> {
529        if spec.resume.is_some() {
530            return Err(EngineError::Backend(
531                "acp backend: resume is unsupported (session/load is an optional v1 \
532                 capability this backend does not negotiate)"
533                    .to_string(),
534            ));
535        }
536        let model = spec.model.clone();
537
538        let mut command = tokio::process::Command::new(&self.program);
539        command
540            .args(&self.args)
541            .current_dir(&spec.cwd)
542            // agent-env-clear: CLEARED env from the minimal allowlist. ACP
543            // defines no canonical auth env var, so NO ambient credential is
544            // injected (auth_env_name None) — the peer authenticates from
545            // its own config or fails loudly, never by inheritance.
546            .env_clear()
547            .envs(crate::agent_env::agent_session_env(
548                &spec.env,
549                &spec.session_id,
550                None,
551            ))
552            // ACP is bidirectional: stdin carries the client's requests.
553            .stdin(Stdio::piped())
554            .stdout(Stdio::piped())
555            .stderr(Stdio::piped())
556            .kill_on_drop(true);
557        // Unix: make the child the leader of a fresh process group so aborts
558        // can kill the whole tree, mirroring `backend_claude::ClaudeBackend`.
559        #[cfg(unix)]
560        command.process_group(0);
561
562        let mut child = command.spawn().map_err(|e| {
563            EngineError::Backend(format!(
564                "failed to spawn acp agent {}: {e}",
565                self.program.display()
566            ))
567        })?;
568
569        // Windows: kill-on-close Job Object, mirroring `backend_claude`.
570        #[cfg(windows)]
571        let job = match child.raw_handle() {
572            Some(handle) => match win_job::JobHandle::create_and_assign(handle) {
573                Ok(job) => Some(job),
574                Err(e) => {
575                    tracing::warn!(error = %e, "failed to create Job Object for acp child; \
576                        tree-kill on abort will be unavailable");
577                    None
578                }
579            },
580            None => None,
581        };
582
583        let stdin = child
584            .stdin
585            .take()
586            .ok_or_else(|| EngineError::Backend("acp child has no stdin pipe".to_string()))?;
587        let stdout = child
588            .stdout
589            .take()
590            .ok_or_else(|| EngineError::Backend("acp child has no stdout pipe".to_string()))?;
591        let stderr = child
592            .stderr
593            .take()
594            .ok_or_else(|| EngineError::Backend("acp child has no stderr pipe".to_string()))?;
595
596        // Capture stderr concurrently so a chatty child never blocks on a
597        // full pipe and failure messages can include the tail. The stream is
598        // drained to EOF but only a bounded tail is retained — a noisy or
599        // malicious peer must not exhaust host memory (stream_bounds).
600        let stderr_buf = Arc::new(Mutex::new(String::new()));
601        let stderr_task = {
602            let buf = Arc::clone(&stderr_buf);
603            tokio::spawn(async move {
604                let tail = drain_to_tail(stderr, STDERR_TAIL_CAP).await;
605                *buf.lock().expect("stderr buffer lock") = tail;
606            })
607        };
608
609        let mut session = AcpSession {
610            session_id: spec.session_id.clone(),
611            acp_session_id: None,
612            model,
613            spec,
614            child,
615            #[cfg(windows)]
616            job,
617            stdin,
618            lines: BoundedLines::new(stdout),
619            stderr_buf,
620            stderr_task: Some(stderr_task),
621            queue: VecDeque::new(),
622            next_request_id: 1,
623            prompt_request_id: None,
624            tool_calls: HashMap::new(),
625            message_text: String::new(),
626            message_id: None,
627            last_usage: None,
628            saw_result: false,
629            saw_success_result: false,
630            exit: None,
631        };
632
633        // The handshake is eager: a non-ACP executable (or a hung peer)
634        // fails start() loudly rather than mid-run. A handshake failure
635        // kills the child before the error crosses back.
636        if let Err(e) = session.handshake().await {
637            session.kill_child().await;
638            return Err(e);
639        }
640        Ok(Box::new(session))
641    }
642}
643
644// ---------------------------------------------------------------------------
645// Session
646// ---------------------------------------------------------------------------
647
648/// A live ACP session (the [`AgentSession`] impl).
649///
650/// Reading is INLINE in [`AcpSession::next_event`] (no reader task), which
651/// keeps memory bounded by [`BoundedLines`] and is deadlock-free for this
652/// protocol: the peer blocks waiting for each permission answer, and the
653/// read loop answers permission requests synchronously as they arrive, so
654/// stdin writes never wait on a peer that is itself waiting on a full
655/// stdout pipe.
656pub struct AcpSession {
657    session_id: String,
658    /// The peer-issued session id (`session/new` response).
659    acp_session_id: Option<String>,
660    model: String,
661    /// Kept for permission decisions (`disallowed_tools`, `writable`).
662    spec: SessionSpec,
663    child: Child,
664    #[cfg(windows)]
665    job: Option<win_job::JobHandle>,
666    stdin: ChildStdin,
667    lines: BoundedLines<ChildStdout>,
668    stderr_buf: Arc<Mutex<String>>,
669    stderr_task: Option<JoinHandle<()>>,
670    /// Converted events not yet surfaced; popped one per `next_event`.
671    queue: VecDeque<AgentEvent>,
672    next_request_id: u64,
673    /// The in-flight `session/prompt` request id; its response synthesizes
674    /// the terminal `Result`. `None` outside a prompt turn.
675    prompt_request_id: Option<u64>,
676    /// Tracked tool calls by `toolCallId` (kind/title/subject), so a
677    /// `tool_call_update` or permission request resolves to what is known
678    /// about the call.
679    tool_calls: HashMap<String, ToolCallInfo>,
680    /// Accumulated text of the CURRENT assistant message (chunks with the
681    /// same `messageId` concatenate; a changed/missing-`messageId` boundary
682    /// starts a new message, and the last message wins the terminal Result,
683    /// mirroring `backend_codex`'s last-agent_message rule).
684    message_text: String,
685    message_id: Option<String>,
686    /// Latest `usage_update` (context state + optional cumulative USD cost);
687    /// its cost lands on the terminal `Result` (see module docs).
688    last_usage: Option<Value>,
689    saw_result: bool,
690    saw_success_result: bool,
691    exit: Option<SessionExit>,
692}
693
694#[cfg(unix)]
695impl Drop for AcpSession {
696    fn drop(&mut self) {
697        crate::backend_claude::kill_unreaped_group(&self.child);
698    }
699}
700
701impl AcpSession {
702    // -- wire helpers -------------------------------------------------------
703
704    /// Serialize one JSON-RPC message as a single NDJSON line on the peer's
705    /// stdin. Keys are inserted in sorted order by serde_json's default map,
706    /// which also puts `"id"` first — mock peers and debugging tools rely on
707    /// nothing more than JSON semantics, but stable key order keeps captured
708    /// transcripts diffable.
709    async fn write_message(&mut self, message: Value) -> Result<()> {
710        use tokio::io::AsyncWriteExt;
711        let mut line = serde_json::to_string(&message)
712            .map_err(|e| EngineError::Backend(format!("failed to encode acp message: {e}")))?;
713        line.push('\n');
714        self.stdin.write_all(line.as_bytes()).await.map_err(|e| {
715            EngineError::Backend(format!("failed to write to acp agent stdin: {e}"))
716        })?;
717        self.stdin
718            .flush()
719            .await
720            .map_err(|e| EngineError::Backend(format!("failed to flush acp agent stdin: {e}")))?;
721        Ok(())
722    }
723
724    /// Send a client request and return its id (the caller either awaits the
725    /// response via [`AcpSession::pump_until_response`] or, for
726    /// `session/prompt`, leaves it to the `next_event` loop).
727    async fn send_request(&mut self, method: &str, params: Value) -> Result<u64> {
728        let id = self.next_request_id;
729        self.next_request_id += 1;
730        self.write_message(json!({
731            "jsonrpc": "2.0",
732            "id": id,
733            "method": method,
734            "params": params,
735        }))
736        .await?;
737        Ok(id)
738    }
739
740    /// `initialize` + `session/new`, then the first `session/prompt` (its
741    /// response streams in through `next_event` like any later turn).
742    async fn handshake(&mut self) -> Result<()> {
743        let init_id = self
744            .send_request(
745                method::INITIALIZE,
746                json!({
747                    "protocolVersion": ACP_PROTOCOL_VERSION,
748                    "clientCapabilities": {
749                        // fs/terminal unsupported: a conformant peer never
750                        // calls fs/* or terminal/*; one that does is answered
751                        // with -32601 rather than silently served.
752                        "fs": { "readTextFile": false, "writeTextFile": false },
753                        "terminal": false,
754                    },
755                    "clientInfo": {
756                        "name": "kranz",
757                        "title": "kranz mission engine",
758                        "version": env!("CARGO_PKG_VERSION"),
759                    },
760                }),
761            )
762            .await?;
763        let init_result = self.pump_until_response(init_id, HANDSHAKE_TIMEOUT).await?;
764        let peer_version = init_result
765            .get("protocolVersion")
766            .and_then(Value::as_u64)
767            .unwrap_or(0);
768        if peer_version != ACP_PROTOCOL_VERSION {
769            return Err(EngineError::Backend(format!(
770                "acp agent negotiated protocol version {peer_version}, but this backend speaks \
771                 only stable version {ACP_PROTOCOL_VERSION} (schema v1)"
772            )));
773        }
774
775        let new_id = self
776            .send_request(
777                method::SESSION_NEW,
778                json!({
779                    "cwd": self.spec.cwd.display().to_string(),
780                    "mcpServers": [],
781                }),
782            )
783            .await?;
784        let new_result = self.pump_until_response(new_id, HANDSHAKE_TIMEOUT).await?;
785        let acp_session_id = new_result
786            .get("sessionId")
787            .and_then(Value::as_str)
788            .ok_or_else(|| {
789                EngineError::Backend("acp session/new response carried no sessionId".to_string())
790            })?
791            .to_string();
792        self.acp_session_id = Some(acp_session_id.clone());
793        self.session_id = acp_session_id.clone();
794        // Init is synthesized from the handshake (no wire line carries it),
795        // mirroring backend_kimi: the configured model is recorded for
796        // attribution — ACP v1 has no model-reporting field.
797        self.queue.push_back(AgentEvent::Init {
798            session_id: acp_session_id,
799            model: self.model.clone(),
800            raw: json!({
801                "initialize": init_result,
802                "sessionNew": new_result,
803                "synthesizedBy": "backend_acp",
804            }),
805        });
806
807        let prompt_text = match &self.spec.prompt {
808            PromptMode::SingleShot(text) | PromptMode::Streaming(text) => text.clone(),
809        };
810        self.send_prompt(&prompt_text).await
811    }
812
813    /// Send one `session/prompt` request and mark its id as the turn whose
814    /// response synthesizes the terminal `Result`.
815    async fn send_prompt(&mut self, text: &str) -> Result<()> {
816        let acp_session_id = self
817            .acp_session_id
818            .clone()
819            .ok_or_else(|| EngineError::Backend("acp session not established yet".to_string()))?;
820        let id = self
821            .send_request(
822                method::SESSION_PROMPT,
823                json!({
824                    "sessionId": acp_session_id,
825                    "prompt": [ { "type": "text", "text": text } ],
826                }),
827            )
828            .await?;
829        self.prompt_request_id = Some(id);
830        // A new turn begins: the terminal Result stitches only this turn's
831        // last message.
832        self.message_text.clear();
833        self.message_id = None;
834        Ok(())
835    }
836
837    /// Read frames until the response to `id` arrives, converting everything
838    /// else through the normal frame path (notifications become queued
839    /// events; permission requests are answered). Used by the handshake —
840    /// the only place a response is awaited synchronously.
841    async fn pump_until_response(
842        &mut self,
843        id: u64,
844        timeout: std::time::Duration,
845    ) -> Result<Value> {
846        let pump = async {
847            loop {
848                let frame = match self.read_frame().await? {
849                    Some(frame) => frame,
850                    None => {
851                        return Err(EngineError::Backend(format!(
852                            "acp agent closed stdout before answering request id {id}; \
853                             stderr tail: {}",
854                            self.stderr_tail()
855                        )))
856                    }
857                };
858                match frame {
859                    Frame::Response {
860                        id: response_id,
861                        outcome,
862                    } if response_id == id => {
863                        return match outcome {
864                            RpcOutcome::Result(result) => Ok(result),
865                            RpcOutcome::Error(error) => Err(EngineError::Backend(format!(
866                                "acp request id {id} failed: {error}"
867                            ))),
868                        };
869                    }
870                    other => self.handle_frame(other).await?,
871                }
872            }
873        };
874        match tokio::time::timeout(timeout, pump).await {
875            Ok(result) => result,
876            Err(_) => Err(EngineError::Backend(format!(
877                "acp agent did not answer request id {id} within {}s (handshake timeout)",
878                timeout.as_secs()
879            ))),
880        }
881    }
882
883    /// Read and classify the next stdout line; `None` at EOF. Blank lines
884    /// are skipped (NDJSON tolerates them; a peer's pretty-printing or
885    /// keepalive must not fabricate events).
886    async fn read_frame(&mut self) -> Result<Option<Frame>> {
887        loop {
888            match self.lines.next_line().await {
889                Ok(Some(line)) if line.trim().is_empty() => continue,
890                Ok(Some(line)) => return Ok(Some(classify_line(&line))),
891                Ok(None) => return Ok(None),
892                Err(e) => {
893                    return Err(EngineError::Backend(format!(
894                        "error reading acp agent stdout: {e}; stderr tail: {}",
895                        self.stderr_tail()
896                    )))
897                }
898            }
899        }
900    }
901
902    /// Convert one frame into queued events and side effects (permission
903    /// answers, tool tracking, usage capture, terminal-Result synthesis).
904    async fn handle_frame(&mut self, frame: Frame) -> Result<()> {
905        match frame {
906            Frame::Notification {
907                method,
908                params,
909                raw,
910            } => {
911                if method == method::SESSION_UPDATE {
912                    self.handle_session_update(&params, raw);
913                } else {
914                    self.queue.push_back(AgentEvent::Other { raw });
915                }
916            }
917            Frame::Request {
918                id,
919                method,
920                params,
921                raw,
922            } => {
923                if method == method::REQUEST_PERMISSION {
924                    self.handle_permission_request(id, &params, raw).await?;
925                } else {
926                    // A client capability we did not advertise (fs/*,
927                    // terminal/*, elicitation/*): refuse with JSON-RPC
928                    // -32601 rather than silently serving or hanging.
929                    self.write_message(json!({
930                        "jsonrpc": "2.0",
931                        "id": id,
932                        "error": {
933                            "code": -32601,
934                            "message": format!("kranz acp backend does not support {method:?}"),
935                        },
936                    }))
937                    .await?;
938                    self.queue.push_back(AgentEvent::Other { raw });
939                }
940            }
941            Frame::Response { id, outcome } => {
942                if Some(id) == self.prompt_request_id {
943                    self.prompt_request_id = None;
944                    self.synthesize_result(outcome, id);
945                } else {
946                    // A response to nothing outstanding (a straggler from a
947                    // cancelled turn, or a peer bug): transcript only.
948                    self.queue.push_back(AgentEvent::Other {
949                        raw: match outcome {
950                            RpcOutcome::Result(result) => {
951                                json!({ "unmatchedResponse": { "id": id, "result": result } })
952                            }
953                            RpcOutcome::Error(error) => {
954                                json!({ "unmatchedResponse": { "id": id, "error": error } })
955                            }
956                        },
957                    });
958                }
959            }
960            Frame::Unrecognized(raw) => {
961                self.queue.push_back(AgentEvent::Other { raw });
962            }
963        }
964        Ok(())
965    }
966
967    /// Map one `session/update` notification onto events (see module docs).
968    fn handle_session_update(&mut self, params: &Value, raw: Value) {
969        let update = params.get("update").cloned().unwrap_or(Value::Null);
970        match update.get("sessionUpdate").and_then(Value::as_str) {
971            Some("agent_message_chunk") => {
972                let text = update
973                    .get("content")
974                    .and_then(|c| c.get("text"))
975                    .and_then(Value::as_str)
976                    .unwrap_or("");
977                if text.is_empty() {
978                    self.queue.push_back(AgentEvent::Other { raw });
979                    return;
980                }
981                // Message boundaries: a changed messageId starts a new
982                // message; the LAST message wins the terminal Result.
983                let chunk_id = update
984                    .get("messageId")
985                    .and_then(Value::as_str)
986                    .map(str::to_string);
987                if chunk_id.is_some() && chunk_id != self.message_id {
988                    self.message_text.clear();
989                    self.message_id = chunk_id;
990                }
991                self.message_text.push_str(text);
992                self.queue.push_back(AgentEvent::Text {
993                    text: text.to_string(),
994                    raw,
995                });
996            }
997            Some("tool_call") => {
998                let id = update
999                    .get("toolCallId")
1000                    .and_then(Value::as_str)
1001                    .unwrap_or_default()
1002                    .to_string();
1003                let kind = update
1004                    .get("kind")
1005                    .and_then(Value::as_str)
1006                    .unwrap_or("other")
1007                    .to_string();
1008                let title = update
1009                    .get("title")
1010                    .and_then(Value::as_str)
1011                    .unwrap_or_default()
1012                    .to_string();
1013                let subject = tool_call_subject(&kind, &title, &update);
1014                self.tool_calls.insert(
1015                    id,
1016                    ToolCallInfo {
1017                        kind: kind.clone(),
1018                        title: title.clone(),
1019                        subject,
1020                    },
1021                );
1022                self.queue.push_back(AgentEvent::ToolUse {
1023                    tool: kind,
1024                    summary: truncate_chars(&title, SUMMARY_MAX_CHARS),
1025                    raw,
1026                });
1027            }
1028            Some("tool_call_update") => {
1029                let id = update
1030                    .get("toolCallId")
1031                    .and_then(Value::as_str)
1032                    .unwrap_or_default()
1033                    .to_string();
1034                let status = update
1035                    .get("status")
1036                    .and_then(Value::as_str)
1037                    .unwrap_or("")
1038                    .to_string();
1039                {
1040                    let tracked = self.tool_calls.entry(id).or_default();
1041                    if let Some(kind) = update.get("kind").and_then(Value::as_str) {
1042                        tracked.kind = kind.to_string();
1043                    }
1044                    if let Some(title) = update.get("title").and_then(Value::as_str) {
1045                        tracked.title = title.to_string();
1046                    }
1047                }
1048                match status.as_str() {
1049                    // Terminal statuses surface as first-class ToolResult
1050                    // events; progress updates (pending/in_progress) are
1051                    // transcript-only. `failed` is a normal failure, NOT a
1052                    // denial (mirrors backend_codex) — denials are
1053                    // synthesized at the permission seam.
1054                    "completed" | "failed" => {
1055                        let tracked = self
1056                            .tool_calls
1057                            .get(
1058                                update
1059                                    .get("toolCallId")
1060                                    .and_then(Value::as_str)
1061                                    .unwrap_or_default(),
1062                            )
1063                            .cloned()
1064                            .unwrap_or_default();
1065                        self.queue.push_back(AgentEvent::ToolResult {
1066                            tool: Some(tracked.kind.clone()),
1067                            denied: false,
1068                            summary: tool_result_summary(&update, &tracked, &status),
1069                            raw,
1070                        });
1071                    }
1072                    _ => self.queue.push_back(AgentEvent::Other { raw }),
1073                }
1074            }
1075            Some("usage_update") => {
1076                // Remembered for the terminal Result's cost; the update
1077                // itself is transcript-only (no AgentEvent kind for
1078                // mid-stream usage, and `used`/`size` are context-window
1079                // state, not a billable token split).
1080                self.last_usage = Some(update.clone());
1081                self.queue.push_back(AgentEvent::Other { raw });
1082            }
1083            _ => self.queue.push_back(AgentEvent::Other { raw }),
1084        }
1085    }
1086
1087    /// Answer one `session/request_permission` at the seam; a refusal is
1088    /// synthesized into a `denied` ToolResult so the guardrail firing is a
1089    /// first-class event (the peer may report nothing itself).
1090    async fn handle_permission_request(
1091        &mut self,
1092        id: Value,
1093        params: &Value,
1094        raw: Value,
1095    ) -> Result<()> {
1096        let call_update = params.get("toolCall").cloned().unwrap_or(Value::Null);
1097        let call_id = call_update
1098            .get("toolCallId")
1099            .and_then(Value::as_str)
1100            .unwrap_or_default()
1101            .to_string();
1102        // Merge what the request carries with what the tool_call/update
1103        // stream already told us about this call.
1104        let mut info = self.tool_calls.get(&call_id).cloned().unwrap_or_default();
1105        if let Some(kind) = call_update.get("kind").and_then(Value::as_str) {
1106            info.kind = kind.to_string();
1107        }
1108        if info.kind.is_empty() {
1109            info.kind = "other".to_string();
1110        }
1111        if let Some(title) = call_update.get("title").and_then(Value::as_str) {
1112            info.title = title.to_string();
1113        }
1114        if info.subject.is_empty() {
1115            info.subject = tool_call_subject(&info.kind, &info.title, &call_update);
1116        }
1117        self.tool_calls.insert(call_id.clone(), info.clone());
1118
1119        let decision = decide_permission(&self.spec, &info);
1120        let options = params
1121            .get("options")
1122            .and_then(Value::as_array)
1123            .cloned()
1124            .unwrap_or_default();
1125        let result = permission_response(&decision, &options);
1126        self.write_message(json!({
1127            "jsonrpc": "2.0",
1128            "id": id,
1129            "result": result,
1130        }))
1131        .await?;
1132
1133        if let PermissionDecision::Deny(reason) = &decision {
1134            tracing::info!(
1135                session_id = %self.session_id,
1136                tool_call_id = %call_id,
1137                kind = %info.kind,
1138                decision = "deny",
1139                reason = %reason,
1140                "acp permission request refused at the kranz seam"
1141            );
1142            self.queue.push_back(AgentEvent::ToolResult {
1143                tool: Some(info.kind.clone()),
1144                denied: true,
1145                summary: truncate_chars(
1146                    &format!("refused by kranz permission seam: {reason}"),
1147                    SUMMARY_MAX_CHARS,
1148                ),
1149                raw,
1150            });
1151        } else {
1152            // The approval itself is not an event — the peer's own
1153            // tool_call_update reports the outcome. The request line stays
1154            // in the transcript.
1155            self.queue.push_back(AgentEvent::Other { raw });
1156        }
1157        Ok(())
1158    }
1159
1160    /// Synthesize the terminal `Result` from a `session/prompt` response
1161    /// (see module docs): text stitched from the turn's last assistant
1162    /// message, `is_error` ⇔ `stopReason != "end_turn"`, cost only when the
1163    /// peer reported a USD amount — absent data stays absent.
1164    ///
1165    /// WHY non-`end_turn` is an error, not just `refusal` (12th-pass review):
1166    /// ACP v1's stop reasons are `end_turn` (natural completion), `refusal`,
1167    /// `max_tokens`, `max_turn_requests`, and `cancelled`. Mapping only
1168    /// `refusal` to `is_error` let a turn cut short by `max_tokens`/
1169    /// `max_turn_requests` — or answered `cancelled`, or carrying a missing
1170    /// or unrecognized reason — surface as a SUCCESSFUL result, so a
1171    /// truncated validator report could pass validation. Fail-closed is the
1172    /// only honest mapping: anything but a natural completion is an error,
1173    /// and the reason string rides the raw payload so the failure is
1174    /// diagnosable (`null` when the peer omitted the field entirely).
1175    fn synthesize_result(&mut self, outcome: RpcOutcome, request_id: u64) {
1176        let last_cost_usd = self
1177            .last_usage
1178            .as_ref()
1179            .and_then(|u| u.get("cost"))
1180            .filter(|cost| {
1181                cost.get("currency").and_then(Value::as_str) == Some("USD")
1182                    && cost.get("amount").and_then(Value::as_f64).is_some()
1183            })
1184            .and_then(|cost| cost.get("amount").and_then(Value::as_f64));
1185        let (text, is_error, raw) = match outcome {
1186            RpcOutcome::Result(result) => {
1187                let stop_reason = result.get("stopReason").and_then(Value::as_str);
1188                (
1189                    std::mem::take(&mut self.message_text),
1190                    stop_reason != Some("end_turn"),
1191                    json!({
1192                        "promptResponse": result,
1193                        "usageUpdate": self.last_usage,
1194                        "synthesizedBy": "backend_acp",
1195                        // The classification input, verbatim: exactly what the
1196                        // peer sent, `null` when it sent nothing — the raw
1197                        // payload always explains WHY a non-end_turn failed.
1198                        "stopReason": stop_reason,
1199                    }),
1200                )
1201            }
1202            RpcOutcome::Error(error) => (
1203                format!("acp session/prompt failed: {error}"),
1204                true,
1205                json!({
1206                    "promptError": error,
1207                    "requestId": request_id,
1208                    "synthesizedBy": "backend_acp",
1209                }),
1210            ),
1211        };
1212        let event = AgentEvent::Result {
1213            text,
1214            is_error,
1215            usage: TokenUsage::default(),
1216            cost_usd: last_cost_usd,
1217            num_turns: Some(1),
1218            raw,
1219        };
1220        self.observe(&event);
1221        self.queue.push_back(event);
1222    }
1223
1224    fn observe(&mut self, event: &AgentEvent) {
1225        if let AgentEvent::Result { is_error, .. } = event {
1226            self.saw_result = true;
1227            if !is_error {
1228                self.saw_success_result = true;
1229            }
1230        }
1231    }
1232
1233    /// Kill the child and reap it, best-effort; also joins the stderr
1234    /// capture task. Mirrors `backend_kimi::KimiSession::kill_child`
1235    /// exactly: unix process-group SIGKILL (with a post-reap sweep for
1236    /// stragglers that raced a mid-fork), windows kill-on-close Job Object.
1237    async fn kill_child(&mut self) {
1238        #[cfg(unix)]
1239        {
1240            let pgid = self
1241                .child
1242                .id()
1243                .and_then(|pid| i32::try_from(pid).ok())
1244                .filter(|pid| *pid > 0);
1245            let group_killed = matches!(pgid, Some(pgid) if kill_group(pgid));
1246            if !group_killed {
1247                let _ = self.child.start_kill();
1248            }
1249            let _ = self.child.wait().await;
1250            if group_killed {
1251                if let Some(pgid) = pgid {
1252                    let _ = kill_group(pgid);
1253                }
1254            }
1255        }
1256        #[cfg(windows)]
1257        {
1258            match &self.job {
1259                Some(job) => job.kill(),
1260                None => {
1261                    let _ = self.child.start_kill();
1262                }
1263            }
1264            let _ = self.child.wait().await;
1265        }
1266        #[cfg(all(not(unix), not(windows)))]
1267        {
1268            let _ = self.child.start_kill();
1269            let _ = self.child.wait().await;
1270        }
1271        if let Some(task) = self.stderr_task.take() {
1272            let _ = task.await;
1273        }
1274    }
1275
1276    async fn finish_at_eof(&mut self) {
1277        let status = self.child.wait().await;
1278        if let Some(task) = self.stderr_task.take() {
1279            let _ = task.await;
1280        }
1281        let exit = match status {
1282            Ok(status) if status.success() && self.saw_result => SessionExit::Completed,
1283            Ok(status) => SessionExit::Failed(format!(
1284                "acp agent exited with {status}{}; stderr tail: {}",
1285                if self.saw_result {
1286                    ""
1287                } else {
1288                    " without answering session/prompt"
1289                },
1290                self.stderr_tail(),
1291            )),
1292            Err(e) => SessionExit::Failed(format!(
1293                "failed to reap acp agent process: {e}; stderr tail: {}",
1294                self.stderr_tail(),
1295            )),
1296        };
1297        self.exit = Some(exit);
1298    }
1299
1300    fn stderr_tail(&self) -> String {
1301        let captured = self
1302            .stderr_buf
1303            .lock()
1304            .map(|guard| guard.clone())
1305            .unwrap_or_default();
1306        last_chars(captured.trim_end(), STDERR_TAIL_CHARS)
1307    }
1308}
1309
1310#[async_trait::async_trait]
1311impl AgentSession for AcpSession {
1312    fn session_id(&self) -> String {
1313        self.session_id.clone()
1314    }
1315
1316    async fn next_event(&mut self) -> Result<Option<AgentEvent>> {
1317        loop {
1318            if let Some(event) = self.queue.pop_front() {
1319                return Ok(Some(event));
1320            }
1321            if self.exit.is_some() {
1322                return Ok(None);
1323            }
1324            let frame = match self.read_frame().await {
1325                Ok(Some(frame)) => frame,
1326                Ok(None) => {
1327                    self.finish_at_eof().await;
1328                    return Ok(None);
1329                }
1330                Err(e) => {
1331                    self.kill_child().await;
1332                    self.exit = Some(SessionExit::Failed(e.to_string()));
1333                    return Ok(None);
1334                }
1335            };
1336            if let Err(e) = self.handle_frame(frame).await {
1337                // A transport error mid-session (stdin write failed — the
1338                // peer is gone): fail the session honestly rather than hang.
1339                self.kill_child().await;
1340                self.exit = Some(SessionExit::Failed(e.to_string()));
1341                return Ok(None);
1342            }
1343        }
1344    }
1345
1346    async fn send_user_message(&mut self, text: &str) -> Result<()> {
1347        if self.exit.is_some() {
1348            return Err(EngineError::Backend(
1349                "acp session is closed; cannot send further messages".to_string(),
1350            ));
1351        }
1352        if self.acp_session_id.is_none() {
1353            return Err(EngineError::Backend(
1354                "acp session not established yet; cannot send a message".to_string(),
1355            ));
1356        }
1357        self.send_prompt(text).await
1358    }
1359
1360    async fn abort(&mut self) -> Result<()> {
1361        // Best-effort graceful cancel first (the peer MAY stop its turn
1362        // cleanly and answer the prompt with stopReason "cancelled"), then
1363        // the house tree-kill regardless — abort must never depend on the
1364        // peer honoring the notification.
1365        if let Some(acp_session_id) = self.acp_session_id.clone() {
1366            let _ = self
1367                .write_message(json!({
1368                    "jsonrpc": "2.0",
1369                    "method": method::SESSION_CANCEL,
1370                    "params": { "sessionId": acp_session_id },
1371                }))
1372                .await;
1373        }
1374        let already_exited = matches!(self.child.try_wait(), Ok(Some(_)));
1375        self.kill_child().await;
1376        if self.saw_success_result && already_exited {
1377            self.exit = Some(SessionExit::Completed);
1378        } else {
1379            self.exit = Some(SessionExit::Aborted);
1380        }
1381        Ok(())
1382    }
1383
1384    fn exit_status(&self) -> Option<SessionExit> {
1385        self.exit.clone()
1386    }
1387}
1388
1389// ---------------------------------------------------------------------------
1390
1391#[cfg(test)]
1392mod tests {
1393    use super::*;
1394
1395    #[test]
1396    fn backend_acp_classify_distinguishes_response_request_notification() {
1397        let response =
1398            classify_line(r#"{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}"#);
1399        assert!(matches!(
1400            response,
1401            Frame::Response {
1402                id: 3,
1403                outcome: RpcOutcome::Result(_)
1404            }
1405        ));
1406        let error =
1407            classify_line(r#"{"jsonrpc":"2.0","id":4,"error":{"code":-32603,"message":"boom"}}"#);
1408        assert!(matches!(
1409            error,
1410            Frame::Response {
1411                id: 4,
1412                outcome: RpcOutcome::Error(_)
1413            }
1414        ));
1415        let request = classify_line(
1416            r#"{"jsonrpc":"2.0","id":100,"method":"session/request_permission","params":{}}"#,
1417        );
1418        assert!(matches!(
1419            request,
1420            Frame::Request { ref method, .. } if method == "session/request_permission"
1421        ));
1422        let notification = classify_line(
1423            r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"plan"}}}"#,
1424        );
1425        assert!(matches!(
1426            notification,
1427            Frame::Notification { ref method, .. } if method == "session/update"
1428        ));
1429        // Garbage is never a hard failure — transcript only.
1430        let torn = classify_line(r#"{"jsonrpc":"2.0","method":"session/upda"#);
1431        assert!(matches!(torn, Frame::Unrecognized(_)));
1432    }
1433
1434    #[test]
1435    fn backend_acp_wildcard_match_anchors_like_a_shell_glob() {
1436        assert!(wildcard_match("git push*", "git push origin main"));
1437        assert!(wildcard_match("git push*", "git push"));
1438        assert!(!wildcard_match("git push*", "git pull"));
1439        assert!(wildcard_match("*", "anything"));
1440        assert!(wildcard_match(
1441            "cargo * --workspace",
1442            "cargo test --workspace"
1443        ));
1444        assert!(!wildcard_match(
1445            "cargo * --workspace",
1446            "cargo test --package x"
1447        ));
1448        assert!(wildcard_match("*/etc/passwd", "/etc/passwd"));
1449        assert!(!wildcard_match("*/etc/passwd", "/etc/passwd.bak"));
1450    }
1451
1452    #[test]
1453    fn backend_acp_pattern_matches_maps_claude_names_to_acp_kinds() {
1454        assert!(pattern_matches(
1455            "Bash(git push*)",
1456            "execute",
1457            "git push origin main"
1458        ));
1459        assert!(!pattern_matches("Bash(git push*)", "execute", "git pull"));
1460        assert!(!pattern_matches(
1461            "Bash(git push*)",
1462            "edit",
1463            "git push origin main"
1464        ));
1465        assert!(pattern_matches("Write", "edit", "/repo/src/main.rs"));
1466        assert!(!pattern_matches("Write", "read", "/repo/src/main.rs"));
1467        assert!(pattern_matches("Edit(/etc/*)", "edit", "/etc/hosts"));
1468        assert!(pattern_matches("Read", "read", "/anywhere"));
1469        // Unknown tool names match nothing (deny-only mapping; never widens).
1470        assert!(!pattern_matches("NotAClaudeTool(*)", "execute", "x"));
1471    }
1472
1473    fn spec_with(writable: bool, disallowed: &[&str]) -> SessionSpec {
1474        SessionSpec {
1475            cwd: PathBuf::from("."),
1476            prompt: PromptMode::SingleShot("do the thing".to_string()),
1477            append_system_prompt: None,
1478            model: "acp-model".to_string(),
1479            effort: "high".to_string(),
1480            session_id: "sess-1".to_string(),
1481            resume: None,
1482            permission_mode: None,
1483            allowed_tools: vec![],
1484            disallowed_tools: disallowed.iter().map(|s| s.to_string()).collect(),
1485            tools: vec![],
1486            writable,
1487            settings_json: None,
1488            json_schema: None,
1489            max_budget_usd: None,
1490            max_turns: None,
1491            env: Default::default(),
1492            sandbox: None,
1493            hook_status: None,
1494        }
1495    }
1496
1497    #[test]
1498    fn backend_acp_permission_denies_disallowed_and_mutating_kinds() {
1499        let spec = spec_with(true, &["Bash(git push*)"]);
1500        let push = ToolCallInfo {
1501            kind: "execute".to_string(),
1502            title: "git push origin main".to_string(),
1503            subject: "git push origin main".to_string(),
1504        };
1505        assert!(matches!(
1506            decide_permission(&spec, &push),
1507            PermissionDecision::Deny(reason) if reason.contains("Bash(git push*)")
1508        ));
1509        let test = ToolCallInfo {
1510            kind: "execute".to_string(),
1511            title: "cargo test".to_string(),
1512            subject: "cargo test".to_string(),
1513        };
1514        assert_eq!(decide_permission(&spec, &test), PermissionDecision::Allow);
1515
1516        // Read-only posture: mutating kinds refused, execute/read allowed.
1517        let ro = spec_with(false, &[]);
1518        let edit = ToolCallInfo {
1519            kind: "edit".to_string(),
1520            title: "write src/main.rs".to_string(),
1521            subject: "/repo/src/main.rs".to_string(),
1522        };
1523        assert!(matches!(
1524            decide_permission(&ro, &edit),
1525            PermissionDecision::Deny(reason) if reason.contains("writable: false")
1526        ));
1527        assert_eq!(decide_permission(&ro, &test), PermissionDecision::Allow);
1528        let read = ToolCallInfo {
1529            kind: "read".to_string(),
1530            title: "read src/main.rs".to_string(),
1531            subject: "/repo/src/main.rs".to_string(),
1532        };
1533        assert_eq!(decide_permission(&ro, &read), PermissionDecision::Allow);
1534    }
1535
1536    /// The permission seam used to fail OPEN when the peer omitted the
1537    /// subject: `wildcard_match("git push*", "")` is false, so no deny fired
1538    /// and the decision was Allow. Every glob-carrying deny rule was
1539    /// bypassable that way, by a peer that need not even be hostile.
1540    #[test]
1541    fn backend_acp_permission_denies_when_the_subject_is_missing() {
1542        let spec = spec_with(true, &["Bash(git push*)"]);
1543        let no_subject = ToolCallInfo {
1544            kind: "execute".to_string(),
1545            title: String::new(),
1546            subject: String::new(),
1547        };
1548        assert!(
1549            matches!(
1550                decide_permission(&spec, &no_subject),
1551                PermissionDecision::Deny(ref reason)
1552                    if reason.contains("no subject") && reason.contains("Bash(git push*)")
1553            ),
1554            "got {:?}",
1555            decide_permission(&spec, &no_subject)
1556        );
1557
1558        // Whitespace is no subject either.
1559        let blank_subject = ToolCallInfo {
1560            subject: "   ".to_string(),
1561            ..no_subject.clone()
1562        };
1563        assert!(matches!(
1564            decide_permission(&spec, &blank_subject),
1565            PermissionDecision::Deny(_)
1566        ));
1567
1568        // Precise: a deny list that does not cover this call's kind is not
1569        // made to fire by a missing subject.
1570        let read_no_subject = ToolCallInfo {
1571            kind: "read".to_string(),
1572            ..no_subject.clone()
1573        };
1574        assert_eq!(
1575            decide_permission(&spec_with(true, &["Bash(git push*)"]), &read_no_subject),
1576            PermissionDecision::Allow
1577        );
1578    }
1579
1580    /// Read-only posture: `MUTATING_KINDS` cannot classify a call whose kind
1581    /// the peer omitted (it arrives as `"other"`), so the containment claim
1582    /// cannot be checked and the call is refused.
1583    #[test]
1584    fn backend_acp_read_only_denies_an_unclassifiable_kind() {
1585        let ro = spec_with(false, &[]);
1586        for kind in ["", "other"] {
1587            let call = ToolCallInfo {
1588                kind: kind.to_string(),
1589                title: "do something".to_string(),
1590                subject: "/repo/src/main.rs".to_string(),
1591            };
1592            assert!(
1593                matches!(
1594                    decide_permission(&ro, &call),
1595                    PermissionDecision::Deny(ref reason) if reason.contains("kind")
1596                ),
1597                "kind {kind:?} got {:?}",
1598                decide_permission(&ro, &call)
1599            );
1600        }
1601        // A writable session still allows an unclassified kind: the read-only
1602        // posture is the only thing that turns on the kind.
1603        let writable = spec_with(true, &[]);
1604        let other = ToolCallInfo {
1605            kind: "other".to_string(),
1606            title: "think".to_string(),
1607            subject: "think".to_string(),
1608        };
1609        assert_eq!(
1610            decide_permission(&writable, &other),
1611            PermissionDecision::Allow
1612        );
1613    }
1614
1615    #[test]
1616    fn backend_acp_permission_response_picks_options_or_cancels() {
1617        let options = vec![
1618            json!({ "optionId": "allow-1", "name": "Allow", "kind": "allow_once" }),
1619            json!({ "optionId": "reject-1", "name": "Reject", "kind": "reject_once" }),
1620        ];
1621        let allow = permission_response(&PermissionDecision::Allow, &options);
1622        assert_eq!(allow["outcome"]["optionId"], json!("allow-1"));
1623        let deny = permission_response(&PermissionDecision::Deny("nope".to_string()), &options);
1624        assert_eq!(deny["outcome"]["optionId"], json!("reject-1"));
1625        // No reject option offered: refusal degrades to the cancelled
1626        // outcome — never an invented selection.
1627        let deny_no_reject = permission_response(
1628            &PermissionDecision::Deny("nope".to_string()),
1629            &[options[0].clone()],
1630        );
1631        assert_eq!(deny_no_reject["outcome"]["outcome"], json!("cancelled"));
1632    }
1633}