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