Skip to main content

phi_ext/phi/
mod.rs

1//! Author-facing SDK for a Phi PXB extension binary — a Rust port of
2//! `ext/go/phi`.
3//!
4//! Build an [`Extension`], register tools / slash commands / event handlers,
5//! then call [`Extension::run`] to speak PXB on stdin/stdout until shutdown:
6//!
7//! ```no_run
8//! use phi_ext::{phi, pxb};
9//!
10//! fn main() -> Result<(), phi::Error> {
11//!     let mut m = phi::Extension::new("hello", "0.1.0");
12//!     m.register_command("hello", phi::Command::new("Say hi", |_args, ctx| {
13//!         ctx.notify("info", "Hello!");
14//!         Ok(())
15//!     }));
16//!     m.subscribe(pxb::Event::SessionStart, |_ev| {});
17//!     m.run()
18//! }
19//! ```
20//!
21//! The run loop is single-threaded. Command handlers receive a [`Context`]
22//! whose methods (`notify`, `confirm`, `submit`, …) forward to the host over
23//! the same pipe — the borrow checker enforces at compile time what the Go
24//! SDK's mutexes enforce at runtime. Tool `execute` handlers may be async
25//! ([`Tool::new_async`]); the SDK drives them to completion on a
26//! single-threaded tokio runtime, so network / IO calls just work.
27
28use crate::pxb;
29use std::collections::HashMap;
30use std::future::Future;
31use std::io;
32use std::pin::Pin;
33
34pub use crate::pxb::Error;
35
36mod schema;
37pub use schema::Schema;
38
39type Rd = io::StdinLock<'static>;
40type Wr = io::StdoutLock<'static>;
41type EventHandlers = HashMap<u16, Box<dyn FnMut(pxb::EventNotify)>>;
42
43/// Host metadata filled by the hello handshake (and refreshed by
44/// `SessionMeta` pushes).
45#[derive(Debug, Clone, Default)]
46pub struct HostInfo {
47    pub cwd: String,
48    pub session_id: String,
49    pub extension_dir: String,
50    pub phi_version: String,
51}
52
53/// An LLM-callable tool. `schema` is a typed JSON Schema for parameters
54/// (same role as Go's `Parameters` / Codex's schemars-generated input schema).
55/// The `execute` handler returns a boxed future so sync ([`Tool::new`]) and
56/// async ([`Tool::new_async`]) handlers share one storage type; it is run to
57/// completion on a single-threaded tokio runtime, blocking the PXB loop the
58/// same way a sync handler does (the host waits for the result anyway).
59#[allow(clippy::type_complexity)] // execute / detail signatures mirror the Go SDK
60pub struct Tool {
61    pub name: String,
62    pub description: String,
63    pub schema: Schema,
64    /// Side-effect-free: the host may run a batch of Readable calls
65    /// concurrently. Set with [`Tool::readable`].
66    pub readable: bool,
67    /// Host RPC wait for `execute`, in seconds. `0` = host default (30s).
68    pub timeout_sec: u32,
69    /// Optional one-line TUI detail from raw JSON args (before execute).
70    pub detail_from_args: Option<Box<dyn FnMut(&[u8]) -> String>>,
71    /// Tool handler: takes raw JSON args, returns a future yielding the
72    /// result. Not `Send` — the run loop is single-threaded, so handlers may
73    /// capture non-`Send` state.
74    pub execute: Box<dyn FnMut(&[u8]) -> Pin<Box<dyn Future<Output = Result<ToolResult, String>>>>>,
75}
76
77impl Tool {
78    pub fn new(
79        name: impl Into<String>,
80        description: impl Into<String>,
81        schema: impl Into<Schema>,
82        mut execute: impl FnMut(&[u8]) -> Result<ToolResult, String> + 'static,
83    ) -> Self {
84        Self {
85            name: name.into(),
86            description: description.into(),
87            schema: schema.into(),
88            timeout_sec: 0,
89            detail_from_args: None,
90            readable: false,
91            // Sync handler: call it eagerly, hand the host a ready future.
92            execute: Box::new(move |args| {
93                let result = execute(args);
94                Box::pin(async move { result })
95            }),
96        }
97    }
98
99    /// Builds a tool with an async handler (network / IO friendly). The
100    /// closure returns a future that the SDK drives to completion on its
101    /// single-threaded runtime when the host invokes the tool. Args are
102    /// owned (`Vec<u8>`) so `|args| async move { … }` can capture them
103    /// directly in a `'static` future.
104    pub fn new_async<F, Fut>(
105        name: impl Into<String>,
106        description: impl Into<String>,
107        schema: impl Into<Schema>,
108        mut execute: F,
109    ) -> Self
110    where
111        F: FnMut(Vec<u8>) -> Fut + 'static,
112        Fut: Future<Output = Result<ToolResult, String>> + 'static,
113    {
114        Self {
115            name: name.into(),
116            description: description.into(),
117            schema: schema.into(),
118            timeout_sec: 0,
119            readable: false,
120            detail_from_args: None,
121            execute: Box::new(move |args| Box::pin(execute(args.to_vec()))),
122        }
123    }
124
125    /// Sets how long the host waits for this tool's result (1–3600; host clamps).
126    pub fn timeout_sec(mut self, secs: u32) -> Self {
127        self.timeout_sec = secs;
128        self
129    }
130
131    /// Sets a one-line TUI detail formatter for raw JSON arguments.
132    pub fn detail_from_args(mut self, f: impl FnMut(&[u8]) -> String + 'static) -> Self {
133        self.detail_from_args = Some(Box::new(f));
134        self
135    }
136
137    /// Marks this tool side-effect-free so the host may run a batch of
138    /// Readable calls concurrently.
139    pub fn readable(mut self) -> Self {
140        self.readable = true;
141        self
142    }
143}
144
145/// Outcome of a [`Tool`] execution.
146#[derive(Debug, Clone, Default)]
147pub struct ToolResult {
148    pub content: String,
149    pub detail: String,
150    pub output: String,
151}
152
153/// A slash command. The handler receives the raw argument string and a
154/// [`Context`] for host interaction (notify / confirm / submit / …).
155#[allow(clippy::type_complexity)] // handler signature mirrors the Go SDK contract
156pub struct Command {
157    pub description: String,
158    /// Leave `/name ` in the composer on picker accept / bare submit.
159    pub needs_args: bool,
160    pub handler: Box<dyn FnMut(&str, &mut Context<'_>) -> Result<(), String>>,
161}
162
163impl Command {
164    pub fn new(
165        description: impl Into<String>,
166        handler: impl FnMut(&str, &mut Context<'_>) -> Result<(), String> + 'static,
167    ) -> Self {
168        Self {
169            description: description.into(),
170            needs_args: false,
171            handler: Box::new(handler),
172        }
173    }
174
175    /// Mark this command as requiring arguments so the host fills the
176    /// composer instead of auto-submitting an empty invocation.
177    pub fn needs_args(mut self) -> Self {
178        self.needs_args = true;
179        self
180    }
181}
182
183/// Modal yes/no dialog shown by the host.
184#[derive(Debug, Clone, Default, serde::Serialize)]
185#[serde(rename_all = "PascalCase")] // host unmarshals into Go's ext.ConfirmRequest
186pub struct ConfirmRequest {
187    pub title: String,
188    pub message: String,
189    pub yes: String, // host default: "Yes"
190    pub no: String,  // host default: "No"
191    pub danger: bool,
192}
193
194/// The user's choice for a [`ConfirmRequest`].
195#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
196pub struct ConfirmReply {
197    pub ok: bool,
198}
199
200#[derive(Debug, Clone, Default)]
201pub struct ToolCallEvent {
202    pub tool_name: String,
203    pub tool_call_id: String,
204    pub input: Vec<u8>,
205}
206
207#[derive(Debug, Clone, Default)]
208pub struct ToolCallResult {
209    pub block: bool,
210    pub reason: String,
211    /// `Some` rewrites the tool input; `None` keeps it.
212    pub input: Option<Vec<u8>>,
213    pub context: String,
214}
215
216#[derive(Debug, Clone, Default)]
217pub struct ToolResultEvent {
218    pub tool_name: String,
219    pub tool_call_id: String,
220    pub input: Vec<u8>,
221    pub content: String,
222    pub is_error: bool,
223    pub err: String,
224}
225
226#[derive(Debug, Clone, Default)]
227pub struct ToolResultResult {
228    /// `Some` rewrites the tool output; `None` keeps it.
229    pub content: Option<String>,
230    pub context: String,
231    /// Ends the agent loop.
232    pub stop: bool,
233    pub reason: String,
234}
235
236#[derive(Debug, Clone, Default)]
237pub struct BeforeAgentStartEvent {
238    pub prompt: String,
239}
240
241#[derive(Debug, Clone, Default)]
242pub struct BeforeAgentStartResult {
243    /// `Some` replaces the user prompt.
244    pub prompt: Option<String>,
245    pub system_prompt_append: String,
246}
247
248#[derive(Debug, Clone, Default)]
249pub struct SessionBeforeSwitchEvent {
250    pub reason: String,
251    pub target_session_id: String,
252}
253
254#[derive(Debug, Clone, Default)]
255pub struct SessionBeforeSwitchResult {
256    pub cancel: bool,
257    pub reason: String,
258    pub toast: String,
259}
260
261#[derive(Debug, Clone, Default)]
262pub struct UserInputEvent {
263    pub text: String,
264}
265
266#[derive(Debug, Clone, Default)]
267pub struct UserInputResult {
268    /// `true` swallows the prompt (no agent loop).
269    pub handled: bool,
270    /// `Some` replaces the prompt text.
271    pub text: Option<String>,
272    pub reason: String,
273}
274
275#[derive(Debug, Clone, Default)]
276pub struct TurnStoppingEvent {
277    pub turn_index: u32,
278}
279
280#[derive(Debug, Clone, Default)]
281pub struct TurnStoppingResult {
282    /// Forces another agent step.
283    pub continue_: bool,
284    /// Injected as a user message when continuing.
285    pub message: String,
286    pub reason: String,
287}
288
289/// Registered intercept / subscribe handlers. Kept as one struct so `run`
290/// can destructure the [`Extension`] into independent fields (each handler
291/// borrows only what it needs, no interior mutability).
292#[derive(Default)]
293struct Handlers {
294    tool_call: Option<Box<dyn FnMut(ToolCallEvent) -> Option<ToolCallResult>>>,
295    tool_result: Option<Box<dyn FnMut(ToolResultEvent) -> Option<ToolResultResult>>>,
296    before_agent_start:
297        Option<Box<dyn FnMut(BeforeAgentStartEvent) -> Option<BeforeAgentStartResult>>>,
298    session_before_switch:
299        Option<Box<dyn FnMut(SessionBeforeSwitchEvent) -> Option<SessionBeforeSwitchResult>>>,
300    user_input: Option<Box<dyn FnMut(UserInputEvent) -> Option<UserInputResult>>>,
301    turn_stopping: Option<Box<dyn FnMut(TurnStoppingEvent) -> Option<TurnStoppingResult>>>,
302    events: EventHandlers,
303}
304
305/// The author-facing registration surface for a PXB extension binary.
306pub struct Extension {
307    name: String,
308    version: String,
309    tools: Vec<Tool>,
310    commands: Vec<(String, Command)>,
311    events: Vec<pxb::Event>,
312    intercept: Vec<pxb::Event>,
313    handlers: Handlers,
314}
315
316impl Extension {
317    /// Constructs a module; call [`run`](Self::run) once everything is
318    /// registered.
319    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
320        Self {
321            name: name.into(),
322            version: version.into(),
323            tools: Vec::new(),
324            commands: Vec::new(),
325            events: Vec::new(),
326            intercept: Vec::new(),
327            handlers: Handlers::default(),
328        }
329    }
330
331    /// Adds an LLM-callable tool.
332    pub fn register_tool(&mut self, tool: Tool) {
333        if !tool.name.is_empty() {
334            self.tools.push(tool);
335        }
336    }
337
338    /// Adds a slash command (cannot override builtins at host level).
339    pub fn register_command(&mut self, name: impl Into<String>, cmd: Command) {
340        let name = name.into();
341        if !name.is_empty() && !self.commands.iter().any(|(n, _)| *n == name) {
342            self.commands.push((name, cmd));
343        }
344    }
345
346    /// Registers a pre-gate tool_call intercept.
347    pub fn on_tool_call(
348        &mut self,
349        f: impl FnMut(ToolCallEvent) -> Option<ToolCallResult> + 'static,
350    ) {
351        self.handlers.tool_call = Some(Box::new(f));
352        push_unique(&mut self.intercept, pxb::Event::ToolCall);
353    }
354
355    /// Registers a post-tool tool_result intercept.
356    pub fn on_tool_result(
357        &mut self,
358        f: impl FnMut(ToolResultEvent) -> Option<ToolResultResult> + 'static,
359    ) {
360        self.handlers.tool_result = Some(Box::new(f));
361        push_unique(&mut self.intercept, pxb::Event::ToolResult);
362    }
363
364    /// May append system prompt text before the agent loop starts.
365    pub fn on_before_agent_start(
366        &mut self,
367        f: impl FnMut(BeforeAgentStartEvent) -> Option<BeforeAgentStartResult> + 'static,
368    ) {
369        self.handlers.before_agent_start = Some(Box::new(f));
370        push_unique(&mut self.intercept, pxb::Event::BeforeAgentStart);
371    }
372
373    /// May cancel a session switch.
374    pub fn on_session_before_switch(
375        &mut self,
376        f: impl FnMut(SessionBeforeSwitchEvent) -> Option<SessionBeforeSwitchResult> + 'static,
377    ) {
378        self.handlers.session_before_switch = Some(Box::new(f));
379        push_unique(&mut self.intercept, pxb::Event::SessionBeforeSwitch);
380    }
381
382    /// May transform or swallow the user prompt before the agent loop.
383    pub fn on_user_input(
384        &mut self,
385        f: impl FnMut(UserInputEvent) -> Option<UserInputResult> + 'static,
386    ) {
387        self.handlers.user_input = Some(Box::new(f));
388        push_unique(&mut self.intercept, pxb::Event::UserInput);
389    }
390
391    /// May steer another agent step when the model stops with no tools.
392    pub fn on_turn_stopping(
393        &mut self,
394        f: impl FnMut(TurnStoppingEvent) -> Option<TurnStoppingResult> + 'static,
395    ) {
396        self.handlers.turn_stopping = Some(Box::new(f));
397        push_unique(&mut self.intercept, pxb::Event::TurnStopping);
398    }
399
400    /// Adds a fire-and-forget lifecycle listener; the payload is the wire
401    /// `EventNotify`. Unknown events are ignored.
402    pub fn subscribe(&mut self, event: pxb::Event, f: impl FnMut(pxb::EventNotify) + 'static) {
403        let code = event.code();
404        if code == 0 {
405            return;
406        }
407        push_unique(&mut self.events, event);
408        self.handlers.events.insert(code, Box::new(f));
409    }
410
411    /// Speaks PXB on stdin/stdout until the host shuts down.
412    pub fn run(self) -> Result<(), Error> {
413        let stdin = io::stdin();
414        let stdout = io::stdout();
415        let mut rd = stdin.lock();
416        let mut wr = stdout.lock();
417
418        // One single-threaded runtime drives every async tool handler; it
419        // blocks the read loop exactly like a sync handler would.
420        let rt = tokio::runtime::Builder::new_current_thread().build()?;
421
422        let host = handshake(&mut rd, &mut wr, &self)?;
423        register(&mut wr, &self)?;
424
425        // Destructure so each handler owns only the state it mutates,
426        // instead of aliasing `self` (command handlers take `&mut` state).
427        let Extension {
428            tools,
429            commands,
430            handlers,
431            ..
432        } = self;
433        serve(&mut rd, &mut wr, host, tools, commands, handlers, &rt)
434    }
435}
436
437/// Exchanges the HELLO handshake and fills [`HostInfo`] from the host's ack.
438fn handshake(rd: &mut Rd, wr: &mut Wr, ext: &Extension) -> Result<HostInfo, Error> {
439    let mut caps = 0u32;
440    if !ext.commands.is_empty() {
441        caps |= pxb::CAP_COMMANDS;
442    }
443    if !ext.tools.is_empty() {
444        caps |= pxb::CAP_TOOLS;
445    }
446    if !ext.events.is_empty() {
447        caps |= pxb::CAP_EVENTS;
448    }
449    if !ext.intercept.is_empty() {
450        caps |= pxb::CAP_INTERCEPT;
451    }
452
453    let hello = pxb::encode_hello(&pxb::Hello {
454        name: ext.name.clone(),
455        version: ext.version.clone(),
456        caps,
457        protocol: pxb::PROTOCOL_VERSION,
458    });
459    pxb::write_frame(wr, pxb::TYPE_HELLO, 0, 0, &hello)?;
460
461    let f = pxb::read_frame(rd)?;
462    if f.header.typ != pxb::TYPE_HELLO_ACK {
463        return Err(Error::UnexpectedFrame {
464            want: "hello_ack",
465            got: f.header.typ,
466        });
467    }
468    let ack = pxb::decode_hello_ack(&f.body)?;
469    Ok(HostInfo {
470        cwd: ack.cwd,
471        session_id: ack.session_id,
472        extension_dir: ack.extension_dir,
473        phi_version: ack.phi_version,
474    })
475}
476
477/// Announces tools, commands, and subscription interest, then signals READY.
478fn register(wr: &mut Wr, ext: &Extension) -> Result<(), Error> {
479    for tool in &ext.tools {
480        let body = pxb::encode_register_tool(&pxb::RegisterTool {
481            name: tool.name.clone(),
482            description: tool.description.clone(),
483            schema_json: tool.schema.to_json_bytes(),
484            timeout_sec: tool.timeout_sec,
485            has_detail: tool.detail_from_args.is_some(),
486            readable: tool.readable,
487        });
488        pxb::write_frame(wr, pxb::TYPE_REGISTER_TOOL, 0, 0, &body)?;
489    }
490    for (name, cmd) in &ext.commands {
491        let body = pxb::encode_register_command(&pxb::RegisterCommand {
492            name: name.clone(),
493            description: cmd.description.clone(),
494            needs_args: cmd.needs_args,
495        });
496        pxb::write_frame(wr, pxb::TYPE_REGISTER_COMMAND, 0, 0, &body)?;
497    }
498    if !ext.events.is_empty() || !ext.intercept.is_empty() {
499        let body = pxb::encode_subscribe(&pxb::Subscribe {
500            events: ext.events.iter().map(|e| e.code()).collect(),
501            intercept: ext.intercept.iter().map(|e| e.code()).collect(),
502        });
503        pxb::write_frame(wr, pxb::TYPE_SUBSCRIBE, 0, 0, &body)?;
504    }
505    pxb::write_frame(wr, pxb::TYPE_READY, 0, 0, &[])
506}
507
508/// Dispatches frames until the host shuts down, handing each frame type to a
509/// focused handler that borrows only the state it mutates.
510fn serve(
511    rd: &mut Rd,
512    wr: &mut Wr,
513    mut host: HostInfo,
514    mut tools: Vec<Tool>,
515    mut commands: Vec<(String, Command)>,
516    mut handlers: Handlers,
517    rt: &tokio::runtime::Runtime,
518) -> Result<(), Error> {
519    let mut pending_submit: Option<String> = None;
520    let mut next_host_id: u32 = 0;
521
522    loop {
523        let f = pxb::read_frame(rd)?;
524        match pxb::FrameType::from_u16(f.header.typ) {
525            pxb::FrameType::Shutdown => {
526                pxb::write_frame(wr, pxb::TYPE_SHUTDOWN_ACK, 0, 0, &[])?;
527                return Ok(());
528            }
529            pxb::FrameType::CommandInvoked => serve_command(
530                rd,
531                wr,
532                &f,
533                &mut host,
534                &mut commands,
535                &mut handlers.events,
536                &mut pending_submit,
537                &mut next_host_id,
538            )?,
539            pxb::FrameType::ToolInvoke => serve_tool(wr, &f, &mut tools, rt)?,
540            pxb::FrameType::ToolDetailInvoke => serve_tool_detail(wr, &f, &mut tools)?,
541            pxb::FrameType::Intercept => serve_intercept(wr, &f, &mut handlers)?,
542            pxb::FrameType::Event => {
543                if let Ok(ev) = pxb::decode_event_notify(&f.body) {
544                    dispatch_event(&mut handlers.events, ev);
545                }
546            }
547            pxb::FrameType::SessionMeta => {
548                if let Ok(meta) = pxb::decode_session_meta(&f.body) {
549                    apply_session_meta(&mut host, meta);
550                }
551            }
552            // Unknown frame types are already consumed by length; ignore.
553            _ => {}
554        }
555    }
556}
557
558/// Invokes a registered slash-command handler and replies with its outcome.
559/// An unknown command fails with "unknown command".
560///
561/// Commands stay synchronous: their [`Context`] reads nested PXB frames off
562/// the same pipe, which only works on the loop thread. Use async *tool*
563/// handlers for IO-heavy work.
564#[allow(clippy::too_many_arguments)] // the loop lends each state piece separately
565fn serve_command(
566    rd: &mut Rd,
567    wr: &mut Wr,
568    frame: &pxb::Frame,
569    host: &mut HostInfo,
570    commands: &mut [(String, Command)],
571    events: &mut EventHandlers,
572    pending_submit: &mut Option<String>,
573    next_host_id: &mut u32,
574) -> Result<(), Error> {
575    let inv = pxb::decode_command_invoked(&frame.body)?;
576    let mut resp = pxb::CommandResponse {
577        ok: true,
578        ..Default::default()
579    };
580    if let Some((_, cmd)) = commands.iter_mut().find(|(n, _)| *n == inv.name) {
581        let mut ctx = Context {
582            cwd: host.cwd.clone(),
583            session_id: host.session_id.clone(),
584            has_ui: true,
585            rd,
586            wr,
587            host,
588            pending_submit,
589            next_host_id,
590            events,
591        };
592        if let Err(e) = (cmd.handler)(&inv.args, &mut ctx) {
593            resp.ok = false;
594            resp.error = e;
595        }
596    } else {
597        resp.ok = false;
598        resp.error = "unknown command".into();
599    }
600    resp.submit = pending_submit.take().unwrap_or_default();
601    let body = pxb::encode_command_response(&resp);
602    pxb::write_frame(
603        wr,
604        pxb::TYPE_COMMAND_RESPONSE,
605        frame.header.flags,
606        frame.header.id,
607        &body,
608    )?;
609    Ok(())
610}
611
612/// Executes a tool and replies with its result, or an error result when the
613/// tool is unknown or its handler failed. Async handlers are driven to
614/// completion on the extension's single-threaded runtime, so the loop blocks
615/// for the handler the same way it does for a sync one.
616fn serve_tool(
617    wr: &mut Wr,
618    frame: &pxb::Frame,
619    tools: &mut [Tool],
620    rt: &tokio::runtime::Runtime,
621) -> Result<(), Error> {
622    let inv = pxb::decode_tool_invoke(&frame.body)?;
623    let tr = match tools.iter_mut().find(|t| t.name == inv.name) {
624        Some(tool) => match rt.block_on((tool.execute)(&inv.args)) {
625            Ok(res) => pxb::ToolResultMsg {
626                content: res.content,
627                detail: res.detail,
628                output: res.output,
629                ..Default::default()
630            },
631            Err(e) => tool_error(e),
632        },
633        None => tool_error("unknown tool"),
634    };
635    let body = pxb::encode_tool_result(&tr);
636    pxb::write_frame(
637        wr,
638        pxb::TYPE_TOOL_RESULT,
639        frame.header.flags,
640        frame.header.id,
641        &body,
642    )?;
643    Ok(())
644}
645
646/// Returns a one-line TUI detail for raw tool args (or empty when unset/unknown).
647fn serve_tool_detail(wr: &mut Wr, frame: &pxb::Frame, tools: &mut [Tool]) -> Result<(), Error> {
648    let inv = pxb::decode_tool_invoke(&frame.body)?;
649    let detail = tools
650        .iter_mut()
651        .find(|t| t.name == inv.name)
652        .and_then(|t| t.detail_from_args.as_mut())
653        .map(|f| f(&inv.args))
654        .unwrap_or_default();
655    let body = pxb::encode_tool_detail_result(&pxb::ToolDetailResult { detail });
656    pxb::write_frame(
657        wr,
658        pxb::TYPE_TOOL_DETAIL_RESULT,
659        frame.header.flags,
660        frame.header.id,
661        &body,
662    )?;
663    Ok(())
664}
665
666/// Replies to one intercept request with the registered handler's result.
667fn serve_intercept(wr: &mut Wr, frame: &pxb::Frame, handlers: &mut Handlers) -> Result<(), Error> {
668    let req = pxb::decode_intercept_req(&frame.body)?;
669    let resp = handle_intercept(req, handlers);
670    let body = pxb::encode_intercept_resp(&resp);
671    pxb::write_frame(
672        wr,
673        pxb::TYPE_INTERCEPT_RESPONSE,
674        frame.header.flags,
675        frame.header.id,
676        &body,
677    )?;
678    Ok(())
679}
680
681/// Dispatches one intercept request to the registered handler. A missing
682/// handler (or a handler returning `None`) yields an empty response — the
683/// host treats that as "no change".
684fn handle_intercept(req: pxb::InterceptReq, handlers: &mut Handlers) -> pxb::InterceptResp {
685    let mut resp = pxb::InterceptResp::default();
686    match pxb::Event::from_code(req.event) {
687        pxb::Event::ToolCall => {
688            let Some(f) = handlers.tool_call.as_mut() else {
689                return resp;
690            };
691            let Some(r) = f(ToolCallEvent {
692                tool_name: req.tool_name,
693                tool_call_id: req.tool_call_id,
694                input: req.input,
695            }) else {
696                return resp;
697            };
698            resp.block = r.block;
699            resp.reason = r.reason;
700            resp.context = r.context;
701            if let Some(v) = r.input {
702                resp.input = v;
703            }
704        }
705        pxb::Event::ToolResult => {
706            let Some(f) = handlers.tool_result.as_mut() else {
707                return resp;
708            };
709            let Some(r) = f(ToolResultEvent {
710                tool_name: req.tool_name,
711                tool_call_id: req.tool_call_id,
712                input: req.input,
713                content: req.content,
714                is_error: req.is_error,
715                err: req.err_text,
716            }) else {
717                return resp;
718            };
719            resp.context = r.context;
720            resp.stop = r.stop;
721            resp.reason = r.reason;
722            if let Some(v) = r.content {
723                resp.content = v;
724            }
725        }
726        pxb::Event::BeforeAgentStart => {
727            let Some(f) = handlers.before_agent_start.as_mut() else {
728                return resp;
729            };
730            let Some(r) = f(BeforeAgentStartEvent { prompt: req.prompt }) else {
731                return resp;
732            };
733            resp.system_prompt_append = r.system_prompt_append;
734            if let Some(v) = r.prompt {
735                resp.prompt = v;
736            }
737        }
738        pxb::Event::SessionBeforeSwitch => {
739            let Some(f) = handlers.session_before_switch.as_mut() else {
740                return resp;
741            };
742            let Some(r) = f(SessionBeforeSwitchEvent {
743                reason: req.reason,
744                target_session_id: req.target_id,
745            }) else {
746                return resp;
747            };
748            resp.cancel = r.cancel;
749            resp.reason = r.reason;
750            resp.toast = r.toast;
751        }
752        pxb::Event::UserInput => {
753            let Some(f) = handlers.user_input.as_mut() else {
754                return resp;
755            };
756            let Some(r) = f(UserInputEvent { text: req.prompt }) else {
757                return resp;
758            };
759            resp.handled = r.handled;
760            resp.reason = r.reason;
761            if let Some(v) = r.text {
762                resp.prompt = v;
763            }
764        }
765        pxb::Event::TurnStopping => {
766            let Some(f) = handlers.turn_stopping.as_mut() else {
767                return resp;
768            };
769            let Some(r) = f(TurnStoppingEvent {
770                turn_index: req.turn_index,
771            }) else {
772                return resp;
773            };
774            resp.continue_ = r.continue_;
775            resp.prompt = r.message;
776            resp.reason = r.reason;
777        }
778        _ => {}
779    }
780    resp
781}
782
783/// Applies a session-meta push to host info; empty fields mean "no change".
784fn apply_session_meta(host: &mut HostInfo, meta: pxb::SessionMeta) {
785    if !meta.session_id.is_empty() {
786        host.session_id = meta.session_id;
787    }
788    if !meta.cwd.is_empty() {
789        host.cwd = meta.cwd;
790    }
791}
792
793/// Dispatches an event push to its subscriber, if one is registered.
794fn dispatch_event(handlers: &mut EventHandlers, ev: pxb::EventNotify) {
795    if let Some(handler) = handlers.get_mut(&ev.event) {
796        handler(ev);
797    }
798}
799
800/// An error tool result: the message goes to both `error` and `content` so
801/// the host surfaces it whichever field it renders.
802fn tool_error(message: impl Into<String>) -> pxb::ToolResultMsg {
803    let message = message.into();
804    pxb::ToolResultMsg {
805        is_error: true,
806        error: message.clone(),
807        content: message,
808        ..Default::default()
809    }
810}
811
812/// Interaction surface handed to command handlers. All host traffic goes
813/// over the same PXB pipe the run loop owns — a handler may block the loop
814/// (e.g. [`confirm`](Context::confirm) reads nested frames).
815pub struct Context<'a> {
816    pub cwd: String,
817    pub session_id: String,
818    pub has_ui: bool,
819    rd: &'a mut Rd,
820    wr: &'a mut Wr,
821    host: &'a mut HostInfo,
822    pending_submit: &'a mut Option<String>,
823    next_host_id: &'a mut u32,
824    events: &'a mut EventHandlers,
825}
826
827impl Context<'_> {
828    /// Pushes a toast to the host (`level`: `info` | `warning` | `error`).
829    pub fn notify(&mut self, level: &str, message: &str) {
830        let body = pxb::encode_notify(&pxb::NotifyMsg {
831            level: level.into(),
832            message: message.into(),
833            ..Default::default()
834        });
835        let _ = pxb::write_frame(self.wr, pxb::TYPE_NOTIFY, 0, 0, &body);
836    }
837
838    /// Updates the host footer extension status (empty text clears).
839    pub fn set_status(&mut self, text: &str) {
840        let body = pxb::encode_notify(&pxb::NotifyMsg {
841            status: text.into(),
842            status_set: true,
843            ..Default::default()
844        });
845        let _ = pxb::write_frame(self.wr, pxb::TYPE_NOTIFY, 0, 0, &body);
846    }
847
848    /// Queues a prompt for the host to send after the current slash command
849    /// returns.
850    pub fn submit(&mut self, text: &str) {
851        *self.pending_submit = Some(text.to_string());
852    }
853
854    /// Asks the host to enqueue a user turn (fire-and-forget). Safe to call
855    /// from command handlers on the PXB read loop.
856    pub fn send_user_message(&mut self, text: &str) {
857        if text.is_empty() {
858            return;
859        }
860        let body = pxb::encode_host_request(&pxb::HostRequest {
861            method: "send_user_message".into(),
862            arg: text.into(),
863        });
864        let _ = pxb::write_frame(self.wr, pxb::TYPE_HOST_REQUEST, 0, 0, &body);
865    }
866
867    /// Shows a yes/no dialog on the host and waits for the answer.
868    pub fn confirm(&mut self, title: &str, message: &str) -> ConfirmReply {
869        self.confirm_opts(ConfirmRequest {
870            title: title.into(),
871            message: message.into(),
872            ..Default::default()
873        })
874    }
875
876    /// [`confirm`](Self::confirm) with labels / danger styling.
877    pub fn confirm_opts(&mut self, req: ConfirmRequest) -> ConfirmReply {
878        let Some(id) = self.send_host_request("confirm", &confirm_request_json(&req)) else {
879            return ConfirmReply::default();
880        };
881        // Nested read: keep servicing SessionMeta pushes and subscribed
882        // events while waiting for the HostResult that matches our id.
883        loop {
884            let Ok(f) = pxb::read_frame(self.rd) else {
885                return ConfirmReply::default();
886            };
887            if let Some(reply) = self.nested_reply(f, id) {
888                return reply;
889            }
890        }
891    }
892
893    /// Writes a host request carrying a fresh id; returns that id. `None`
894    /// means the write failed — treat the host as gone.
895    fn send_host_request(&mut self, method: &str, arg: &str) -> Option<u32> {
896        *self.next_host_id = self.next_host_id.wrapping_add(1);
897        let id = *self.next_host_id;
898        let body = pxb::encode_host_request(&pxb::HostRequest {
899            method: method.into(),
900            arg: arg.into(),
901        });
902        if pxb::write_frame(self.wr, pxb::TYPE_HOST_REQUEST, pxb::FLAG_HAS_ID, id, &body).is_err() {
903            return None;
904        }
905        Some(id)
906    }
907
908    /// Handles one frame read while waiting on a host result. Returns the
909    /// [`ConfirmReply`] that ends the wait (a matching HostResult, Shutdown,
910    /// or a broken pipe); `None` means the frame was consumed internally.
911    fn nested_reply(&mut self, f: pxb::Frame, want_id: u32) -> Option<ConfirmReply> {
912        match pxb::FrameType::from_u16(f.header.typ) {
913            pxb::FrameType::HostResult => {
914                if f.header.flags & pxb::FLAG_HAS_ID == 0 || f.header.id != want_id {
915                    return None;
916                }
917                let Ok(res) = pxb::decode_host_result(&f.body) else {
918                    return Some(ConfirmReply::default());
919                };
920                Some(ConfirmReply { ok: res.ok })
921            }
922            pxb::FrameType::SessionMeta => {
923                if let Ok(meta) = pxb::decode_session_meta(&f.body) {
924                    apply_session_meta(self.host, meta);
925                }
926                None
927            }
928            pxb::FrameType::Event => {
929                if let Ok(ev) = pxb::decode_event_notify(&f.body) {
930                    dispatch_event(self.events, ev);
931                }
932                None
933            }
934            pxb::FrameType::Shutdown => {
935                let _ = pxb::write_frame(self.wr, pxb::TYPE_SHUTDOWN_ACK, 0, 0, &[]);
936                Some(ConfirmReply::default())
937            }
938            _ => None,
939        }
940    }
941}
942
943/// Serializes a [`ConfirmRequest`] as the JSON the host parses. The host
944/// unmarshals into Go's `ext.ConfirmRequest` (fields `Title`/`Message`/
945/// `Yes`/`No`/`Danger`); `serde`'s `rename_all = "PascalCase"` pins the key
946/// names to that contract, and `serde_json` handles escaping.
947fn confirm_request_json(req: &ConfirmRequest) -> String {
948    serde_json::to_string(req)
949        .expect("ConfirmRequest holds only strings/bool; serialization cannot fail")
950}
951
952fn push_unique(xs: &mut Vec<pxb::Event>, v: pxb::Event) {
953    if !xs.contains(&v) {
954        xs.push(v);
955    }
956}
957
958#[cfg(test)]
959mod tests {
960    use super::*;
961
962    #[test]
963    fn confirm_json_matches_go_field_names() {
964        let req = ConfirmRequest {
965            title: "Delete?".into(),
966            message: "Remove /tmp/x".into(),
967            yes: "Delete".into(),
968            no: "Cancel".into(),
969            danger: true,
970        };
971        assert_eq!(
972            confirm_request_json(&req),
973            r#"{"Title":"Delete?","Message":"Remove /tmp/x","Yes":"Delete","No":"Cancel","Danger":true}"#
974        );
975    }
976
977    #[test]
978    fn confirm_json_escapes_quotes_and_controls() {
979        let req = ConfirmRequest {
980            title: "say \"hi\"\n".into(),
981            ..Default::default()
982        };
983        assert_eq!(
984            confirm_request_json(&req),
985            r#"{"Title":"say \"hi\"\n","Message":"","Yes":"","No":"","Danger":false}"#
986        );
987    }
988
989    #[test]
990    fn push_unique_keeps_first() {
991        let mut xs = Vec::new();
992        push_unique(&mut xs, pxb::Event::ToolCall);
993        push_unique(&mut xs, pxb::Event::ToolCall);
994        push_unique(&mut xs, pxb::Event::AgentEnd);
995        assert_eq!(xs, vec![pxb::Event::ToolCall, pxb::Event::AgentEnd]);
996    }
997}