Skip to main content

warble_claude_code/
pi.rs

1//! Native pi session materialization. This emits discovery artifacts only; it never starts pi.
2//!
3//! pi (the `pi` coding-agent CLI) has no permission system of its own, so this target's whole
4//! job is to author a launch contract that leaves the session nothing but the host-owned MCP
5//! tools: every built-in tool is disabled through an explicit `--tools` allowlist, discovery of
6//! project files, skills, extensions and prompt templates is switched off, the agent directory
7//! is a server-owned directory this target writes, and the first prompt is authored here rather
8//! than injected after spawn. The host owns the process, the transport and the credential.
9
10use crate::codex::{is_dashboard_component, is_persisted_answer_component};
11use crate::error::DispatchError;
12use crate::interactive::{
13    native_analysis_prompt_fragment, native_analysis_terminal_presentation_instructions,
14    native_answer_persistence_instructions, native_dashboard_save_instructions,
15    prepare_interactive_output_with_host, NativeEntryKind, NativeMcpDescriptor, NativePurpose,
16    NativeSessionScope, PiLaunch, NATIVE_DASHBOARD_SAVE_TOOL, NATIVE_MCP_SERVER_NAME,
17    NATIVE_PERSIST_ANSWER_TOOL, NATIVE_QUERY_TOOL,
18};
19use crate::ir::{
20    reject_unsupported_component_composition, validate_ir_version, OutcomeKind, RealizationKind,
21    TriggerKind, WarbleIr,
22};
23use crate::resolve::resolve_capabilities;
24use crate::targets::TargetId;
25use serde_json::json;
26use std::fs;
27use std::path::{Path, PathBuf};
28
29/// Where the authored system prompt lands, relative to the materialization root. pi receives it
30/// through `--system-prompt <path>`, which replaces its default prompt.
31pub const PI_SYSTEM_PROMPT_PATH: &str = ".warble/pi/SYSTEM.md";
32/// The server-owned pi agent directory (`PI_CODING_AGENT_DIR`). pi needs it writable: it creates
33/// an empty credential store and a trust lock there on first start.
34pub const PI_AGENT_DIR_PATH: &str = ".warble/pi/agent";
35pub const PI_SETTINGS_PATH: &str = ".warble/pi/agent/settings.json";
36pub const PI_MCP_CONFIG_PATH: &str = ".warble/pi/agent/mcp.json";
37/// The oldest pi release whose `--tools` allowlist keeps MCP tools when it names them. Older
38/// releases drop every MCP tool under `--tools`, which would leave the session with no tools.
39pub const PI_MINIMUM_VERSION: &str = "1.0.4";
40
41/// The one session model a native pi session runs on, as pi names it: the provider id and the
42/// exact model id inside that provider's catalog (built in, or supplied by the host's
43/// `models.json` in the agent directory). Step tiers are not realized here — see the target's
44/// capability profile — so one model is the whole binding.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct PiModel {
47    pub provider: String,
48    pub model: String,
49}
50
51impl PiModel {
52    /// Parse `<provider>/<model-id>`. The provider id is the part before the first `/`; a model id
53    /// may itself contain `/` (OpenRouter-style ids do).
54    pub fn parse(value: &str) -> Result<Self, DispatchError> {
55        let invalid = || {
56            DispatchError(format!(
57                "--pi-model must be `<provider>/<model-id>` with a non-empty provider id of \
58                 letters, digits, `.`, `_` or `-` and a non-empty model id without whitespace \
59                 or control characters, neither starting with `-`, got {value:?}"
60            ))
61        };
62        if value.len() > 256 {
63            return Err(invalid());
64        }
65        let (provider, model) = value.split_once('/').ok_or_else(invalid)?;
66        // Both values land in flag position of a closed argv; a dash-leading value would be parsed
67        // by pi as an option rather than as the provider or model it stands for.
68        if provider.is_empty()
69            || model.is_empty()
70            || provider.starts_with('-')
71            || model.starts_with('-')
72            || !provider
73                .chars()
74                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
75            || model.chars().any(|c| c.is_whitespace() || c.is_control())
76        {
77            return Err(invalid());
78        }
79        Ok(PiModel {
80            provider: provider.to_string(),
81            model: model.to_string(),
82        })
83    }
84}
85
86pub fn emit_pi_interactive(
87    ir: &WarbleIr,
88    out_dir: &Path,
89    purpose: Option<NativePurpose>,
90    native_scope: Option<NativeSessionScope>,
91    native_mcp: Option<NativeMcpDescriptor>,
92    model: &PiModel,
93) -> Result<(), DispatchError> {
94    emit_pi_interactive_with_host(ir, out_dir, purpose, native_scope, native_mcp, None, model)
95}
96
97/// Explicit host-owned composition, requiring a v5-aware consumer.
98pub fn emit_pi_interactive_with_host(
99    ir: &WarbleIr,
100    out_dir: &Path,
101    purpose: Option<NativePurpose>,
102    native_scope: Option<NativeSessionScope>,
103    native_mcp: Option<NativeMcpDescriptor>,
104    native_host: Option<crate::native_host::NativeHost>,
105    model: &PiModel,
106) -> Result<(), DispatchError> {
107    validate_ir_version(ir)?;
108    if ir.context_binding.absent {
109        return Err(DispatchError::new(
110            "context-free IR is not supported by pi:interactive",
111        ));
112    }
113    let target = TargetId::PiInteractive.as_str();
114    // pi has no purpose-less (v1) launch contract and realizes only analysis today: setup needs
115    // the bootstrap authority channel and context enrichment needs a human-approval apply gate,
116    // neither of which a pi session carries. Name the arm rather than pick a nearby one.
117    let purpose = match purpose {
118        Some(NativePurpose::Analysis) => NativePurpose::Analysis,
119        Some(other) => {
120            return Err(DispatchError(format!(
121                "{target} realizes only --purpose analysis; purpose '{}' is not materializable on this target (wall-hit)",
122                other.as_str()
123            )))
124        }
125        None => {
126            return Err(DispatchError(format!(
127                "{target} requires --purpose analysis with --native-scope and --native-mcp; it has no purpose-less launch contract"
128            )))
129        }
130    };
131    let scope = native_scope.ok_or_else(|| {
132        DispatchError(
133            "native Sessions purpose requires a server-derived native scope descriptor".to_string(),
134        )
135    })?;
136    let descriptor = native_mcp.ok_or_else(|| {
137        DispatchError(format!(
138            "{target} requires --native-mcp: a pi session has no tools other than the host-owned MCP server"
139        ))
140    })?;
141    if let Some(host) = &native_host {
142        host.validate(ir, target, Some(purpose), Some(&scope), true)?;
143    }
144    let mut entry_ir = WarbleIr {
145        components: ir
146            .components
147            .iter()
148            .filter(|node| node.entrypoint)
149            .cloned()
150            .collect(),
151        ..ir.clone()
152    };
153    // Validate the declared verb against all entries before projection can hide ambiguity.
154    purpose.validate_profile(&entry_ir, &scope.entry)?;
155    // The system prompt is built from exactly one component, so the session must be pinned: a
156    // scope entry would ask pi to choose among entries it has no agent mechanism to choose with.
157    let verb = match (scope.entry.kind, scope.entry.pinned_verb()) {
158        (NativeEntryKind::Agent, Some(verb)) => verb.to_string(),
159        _ => {
160            return Err(DispatchError(format!(
161                "native pi sessions pin one component through the scope entry verb and do not support scope entry ({target})"
162            )))
163        }
164    };
165    entry_ir.components.retain(|node| node.id == verb);
166    let ir = &entry_ir;
167    if native_host.is_none() {
168        reject_unsupported_component_composition(ir, target)?;
169    }
170    let node = ir.components.first().ok_or_else(|| {
171        DispatchError(format!(
172            "{target}: the pinned entry '{verb}' is not an entry component of this profile"
173        ))
174    })?;
175    if !matches!(node.trigger.kind, TriggerKind::OneShot)
176        || !matches!(node.realization_kind, RealizationKind::Skill)
177        || !matches!(node.effect.outcome.kind, OutcomeKind::None)
178    {
179        return Err(DispatchError(format!(
180            "{target} cannot materialize component '{}' shape: only a one-shot skill with no outcome is realized on this target (wall-hit)",
181            node.id
182        )));
183    }
184    if native_host
185        .as_ref()
186        .is_none_or(|host| host.tool(&node.id).is_none())
187    {
188        resolve_capabilities(node, target, &TargetId::PiInteractive.profile())?;
189    }
190    // The tool allowlist is derived from component shape exactly the way the Codex discovery
191    // config is: a host plan names its governed root tool; otherwise the persisted-answer shape
192    // gets the persistence and query tools, and the persisted-answer and dashboard shapes both get
193    // the dashboard save tool, matching what Codex enables for the same shapes.
194    let include_persist_answer = is_persisted_answer_component(node);
195    let include_dashboard_save = is_dashboard_component(node) || include_persist_answer;
196    let mut tools: Vec<String> = match &native_host {
197        Some(host) => {
198            let mut names = host
199                .tool_names()
200                .into_iter()
201                .map(str::to_string)
202                .collect::<Vec<_>>();
203            names.sort();
204            names
205        }
206        None => {
207            let mut names = Vec::new();
208            if include_persist_answer {
209                names.push(NATIVE_PERSIST_ANSWER_TOOL.to_string());
210                names.push(NATIVE_QUERY_TOOL.to_string());
211            }
212            if include_dashboard_save {
213                names.push(NATIVE_DASHBOARD_SAVE_TOOL.to_string());
214            }
215            names
216        }
217    };
218    tools.dedup();
219    if tools.is_empty() {
220        return Err(DispatchError(format!(
221            "{target} cannot materialize component '{}': no host MCP tool serves its shape, so the session would have no tools at all (wall-hit)",
222            node.id
223        )));
224    }
225    let signature = node.id.clone();
226    let system_prompt_relative = PathBuf::from(PI_SYSTEM_PROMPT_PATH);
227    let settings_relative = PathBuf::from(PI_SETTINGS_PATH);
228    let mcp_relative = PathBuf::from(PI_MCP_CONFIG_PATH);
229    let run_relative = PathBuf::from("RUN.md");
230    let mut owned_paths = vec![
231        system_prompt_relative.clone(),
232        settings_relative.clone(),
233        mcp_relative.clone(),
234        run_relative.clone(),
235    ];
236    if native_host.is_some() {
237        owned_paths.push(PathBuf::from(".warble/component-plans.json"));
238    }
239    let launch = PiLaunch {
240        provider: model.provider.clone(),
241        model: model.model.clone(),
242        tools: tools
243            .iter()
244            .map(|tool| format!("mcp__{NATIVE_MCP_SERVER_NAME}__{tool}"))
245            .collect(),
246        system_prompt: system_prompt_relative.clone(),
247        agent_dir: PathBuf::from(PI_AGENT_DIR_PATH),
248    };
249    let output = prepare_interactive_output_with_host(
250        out_dir,
251        target,
252        "pi",
253        &signature,
254        &ir.profile,
255        &owned_paths,
256        Some(purpose),
257        Some(scope.clone()),
258        Some(descriptor.clone()),
259        native_host.clone(),
260        Some(launch),
261    )?;
262
263    let system_prompt = match &native_host {
264        Some(host) => format!(
265            "{}\n\n# GenBI analysis\n\n{}\n",
266            output.marker(),
267            host.instructions(node)
268        ),
269        None => build_system_prompt(
270            node,
271            output.marker(),
272            include_persist_answer,
273            include_dashboard_save,
274        ),
275    };
276    let run = build_run(output.marker(), &node.id);
277    let agent_dir = output.root.join(PI_AGENT_DIR_PATH);
278    fs::create_dir_all(&agent_dir)
279        .map_err(|e| DispatchError(format!("create pi agent dir: {e}")))?;
280    fs::write(output.root.join(&system_prompt_relative), system_prompt)
281        .map_err(|e| DispatchError(format!("write pi system prompt: {e}")))?;
282    fs::write(output.root.join(&settings_relative), pi_settings())
283        .map_err(|e| DispatchError(format!("write pi settings: {e}")))?;
284    fs::write(
285        output.root.join(&mcp_relative),
286        descriptor.pi_discovery_config()?,
287    )
288    .map_err(|e| DispatchError(format!("write pi MCP discovery config: {e}")))?;
289    fs::write(output.root.join(&run_relative), run)
290        .map_err(|e| DispatchError(format!("write RUN.md: {e}")))?;
291    output.write_ownership()?;
292    output.write_launch_spec()
293}
294
295/// The server-owned `settings.json`. Trust is refused outright so a project `.pi/` directory can
296/// never widen the tool surface; `defaultTools: []` is a second layer under the `--tools`
297/// allowlist in the launch spec, never the first. Retries are the host's to decide, so pi's own
298/// are off and a provider failure surfaces on the first event instead of after silent retries.
299fn pi_settings() -> String {
300    let settings = json!({
301        "defaultProjectTrust": "never",
302        "defaultTools": [],
303        "enableAnalytics": false,
304        "enableInstallTelemetry": false,
305        "quietStartup": true,
306        "retry": { "enabled": false },
307    });
308    format!(
309        "{}\n",
310        serde_json::to_string_pretty(&settings).expect("settings serialize")
311    )
312}
313
314fn build_system_prompt(
315    node: &crate::ir::ComponentNode,
316    marker: &str,
317    include_persist_answer: bool,
318    include_dashboard_save: bool,
319) -> String {
320    let fragment = native_analysis_prompt_fragment(&node.prompt_fragment);
321    let body = match &node.brief {
322        Some(brief) => format!("{brief}\n\n{fragment}"),
323        None => fragment,
324    };
325    let mut prompt = format!(
326        "{marker}\n\n# GenBI analysis\n\n\
327         Operate only within the server-bound project scope. Do not change cwd or follow a \
328         caller-supplied project path.\n\n{body}\n\n\
329         You have no shell, file, or network tools. The only tools available are the \
330         `{NATIVE_MCP_SERVER_NAME}` MCP tools the host advertised. Do not read credentials or \
331         expose raw material, and do not report a result you did not obtain through those tools.\n"
332    );
333    if include_persist_answer {
334        prompt.push('\n');
335        prompt.push_str(native_answer_persistence_instructions());
336    }
337    prompt.push('\n');
338    prompt.push_str(native_analysis_terminal_presentation_instructions());
339    if include_dashboard_save {
340        prompt.push('\n');
341        prompt.push_str(native_dashboard_save_instructions());
342    }
343    prompt
344}
345
346fn build_run(marker: &str, verb: &str) -> String {
347    format!(
348        "{marker}\n# Native pi analysis session\n\n\
349         Read `.warble/interactive-launch.json`, then start `pi` in its canonical cwd with exactly \
350         its `argv` (an RPC session) or exactly `pi.json_argv` (one JSON-mode turn), with \
351         `PI_CODING_AGENT_DIR` set to `pi.agent_dir` and the MCP credential supplied in the \
352         `mcp.credential_env_var` environment variable. Over RPC the caller sends `pi.first_prompt` \
353         as the first `prompt` command. pi's built-in tools are disabled; the session is pinned to \
354         the `{verb}` component and may use only the `pi.tools` MCP tools. The caller owns the \
355         process, transport, transcript, and session lifecycle, and judges success from the event \
356         stream, never from the exit code.\n"
357    )
358}