Skip to main content

warble_claude_code/
codex.rs

1//! Native Codex TUI materialization. This emits discovery artifacts only; it never starts Codex.
2
3use crate::error::DispatchError;
4use crate::interactive::{
5    native_analysis_prompt_fragment, native_analysis_terminal_presentation_instructions,
6    native_answer_persistence_instructions, native_context_enrichment_prompt_fragment,
7    native_context_enrichment_terminal_presentation_instructions,
8    native_dashboard_save_instructions, prepare_interactive_output,
9    setup_bootstrap_authority_instructions, setup_recovery_instructions, NativeMcpDescriptor,
10    NativePurpose, NativeSessionScope,
11};
12use crate::ir::{validate_ir_version, OutcomeKind, RealizationKind, TriggerKind, WarbleIr};
13use crate::resolve::resolve_capabilities;
14use crate::targets::TargetId;
15use std::fs;
16use std::path::Path;
17
18pub fn emit_codex_interactive(
19    ir: &WarbleIr,
20    out_dir: &Path,
21    purpose: Option<NativePurpose>,
22    native_scope: Option<NativeSessionScope>,
23    native_mcp: Option<NativeMcpDescriptor>,
24) -> Result<(), DispatchError> {
25    validate_ir_version(ir)?;
26    if let Some(purpose) = purpose {
27        purpose.validate_profile(ir)?;
28    }
29    let materializable = ir
30        .components
31        .iter()
32        .filter(|node| {
33            !node
34                .required_capabilities
35                .iter()
36                .any(|capability| capability == "enrichment_apply:deterministic")
37        })
38        .cloned()
39        .collect::<Vec<_>>();
40    if materializable.is_empty() {
41        return Err(DispatchError("apply_enrichment cannot be dispatched on codex:interactive: native materialization has no enforceable human-approval apply channel (wall-hit)".to_string()));
42    }
43    let materialized = WarbleIr {
44        components: materializable,
45        ..ir.clone()
46    };
47    let ir = &materialized;
48    let target = TargetId::CodexInteractive.as_str();
49    let signature = ir
50        .components
51        .iter()
52        .map(|node| node.id.as_str())
53        .collect::<Vec<_>>()
54        .join(",");
55    let skill_name = purpose.map_or("genbi-enrich-context", NativePurpose::codex_skill);
56    let skill_relative = Path::new(".agents/skills")
57        .join(skill_name)
58        .join("SKILL.md");
59    let agents_relative = Path::new("AGENTS.md").to_path_buf();
60    let run_relative = Path::new("RUN.md").to_path_buf();
61    let mut owned_paths = vec![
62        skill_relative.clone(),
63        agents_relative.clone(),
64        run_relative.clone(),
65    ];
66    if purpose.is_some() {
67        owned_paths.push(Path::new(".codex/config.toml").to_path_buf());
68        owned_paths.push(Path::new(".codex/hooks.json").to_path_buf());
69        owned_paths.push(Path::new(".warble/capture-codex-thread.sh").to_path_buf());
70    }
71    let output = prepare_interactive_output(
72        out_dir,
73        target,
74        "codex",
75        &signature,
76        &owned_paths,
77        purpose,
78        native_scope.clone(),
79        native_mcp.clone(),
80    )?;
81    // `prepare_interactive_output` has now validated the purpose/scope pairing without writing
82    // any artifact, so a direct library caller receives the regular dispatch error rather than a
83    // panic when it omits the required native scope.
84    let codex_permission_profile = if purpose.is_some() {
85        native_scope
86            .as_ref()
87            .expect("validated native purpose carries a scope")
88            .codex_permission_profile()?
89    } else {
90        String::new()
91    };
92
93    for node in &ir.components {
94        if !matches!(node.trigger.kind, TriggerKind::OneShot)
95            || !matches!(
96                node.realization_kind,
97                RealizationKind::Skill | RealizationKind::GatedTool
98            )
99            || !matches!(
100                node.effect.outcome.kind,
101                OutcomeKind::None | OutcomeKind::Mutation
102            )
103        {
104            return Err(DispatchError(format!(
105                "{} cannot materialize component '{}' shape on codex:interactive (wall-hit)",
106                target, node.id
107            )));
108        }
109        resolve_capabilities(node, target, &TargetId::CodexInteractive.profile())?;
110    }
111
112    let include_setup_recovery_instructions =
113        purpose == Some(NativePurpose::Setup) && native_mcp.is_some();
114    let include_persist_answer_instructions = purpose == Some(NativePurpose::Analysis)
115        && native_mcp.is_some()
116        && ir.components.iter().any(is_persisted_answer_component);
117    let skill = build_skill(
118        ir,
119        output.marker(),
120        purpose,
121        include_setup_recovery_instructions,
122        include_persist_answer_instructions,
123        native_mcp.is_some(),
124    );
125    let agents = build_agents(output.marker(), purpose);
126    let run = build_run(output.marker(), skill_name, purpose);
127    let skill_path = output.root.join(skill_relative);
128    fs::create_dir_all(skill_path.parent().expect("skill parent"))
129        .map_err(|e| DispatchError(format!("create Codex skill dir: {e}")))?;
130    fs::write(skill_path, skill).map_err(|e| DispatchError(format!("write Codex skill: {e}")))?;
131    fs::write(output.root.join(agents_relative), agents)
132        .map_err(|e| DispatchError(format!("write AGENTS.md: {e}")))?;
133    fs::write(output.root.join(run_relative), run)
134        .map_err(|e| DispatchError(format!("write RUN.md: {e}")))?;
135    if purpose.is_some() {
136        let codex_dir = output.root.join(".codex");
137        fs::create_dir_all(&codex_dir)
138            .map_err(|e| DispatchError(format!("create Codex discovery dir: {e}")))?;
139        let mcp_discovery = native_mcp.map_or_else(String::new, |descriptor| {
140            descriptor.codex_discovery_config(
141                include_setup_recovery_instructions,
142                purpose == Some(NativePurpose::Analysis)
143                    && ir.components.iter().any(is_dashboard_component),
144                include_persist_answer_instructions,
145            )
146        });
147        fs::write(
148            codex_dir.join("config.toml"),
149            format!("{codex_permission_profile}\n{mcp_discovery}"),
150        )
151        .map_err(|e| DispatchError(format!("write Codex native session config: {e}")))?;
152        fs::write(codex_dir.join("hooks.json"), codex_resume_capture_hooks())
153            .map_err(|e| DispatchError(format!("write Codex native session hooks: {e}")))?;
154        let capture = output.root.join(".warble/capture-codex-thread.sh");
155        fs::create_dir_all(capture.parent().expect("capture parent"))
156            .map_err(|e| DispatchError(format!("create Codex native resume capture dir: {e}")))?;
157        fs::write(&capture, CODEX_RESUME_CAPTURE_SCRIPT)
158            .map_err(|e| DispatchError(format!("write Codex native resume capture: {e}")))?;
159        #[cfg(unix)]
160        {
161            use std::os::unix::fs::PermissionsExt;
162            fs::set_permissions(&capture, fs::Permissions::from_mode(0o700))
163                .map_err(|e| DispatchError(format!("secure Codex native resume capture: {e}")))?;
164        }
165    }
166    output.write_ownership()?;
167    output.write_launch_spec()
168}
169
170const CODEX_RESUME_CAPTURE_SCRIPT: &str = r#"#!/bin/sh
171set -eu
172payload=$(cat)
173thread_id=$(printf '%s\n' "$payload" | sed -n 's/^[[:space:]]*{[[:space:]]*"session_id"[[:space:]]*:[[:space:]]*"\([0-9a-fA-F-]*\)".*/\1/p')
174case "$thread_id" in
175  ????????-????-[12345678]???-[89abAB]???-????????????)
176    umask 077
177    printf '%s\n' "$thread_id" > .warble/codex-thread-id
178    ;;
179  *) exit 1 ;;
180esac
181"#;
182
183fn codex_resume_capture_hooks() -> String {
184    serde_json::to_string_pretty(&serde_json::json!({
185        "description": "Capture only this isolated native Codex thread identifier for host-owned resume.",
186        "hooks": {
187            "UserPromptSubmit": [{
188                "hooks": [{
189                    "type": "command",
190                    "command": "/bin/sh .warble/capture-codex-thread.sh",
191                    "timeout": 3,
192                    "statusMessage": "Securing native session resume"
193                }]
194            }]
195        }
196    })).expect("Codex native hooks serialize")
197}
198
199fn build_skill(
200    ir: &WarbleIr,
201    marker: &str,
202    purpose: Option<NativePurpose>,
203    include_setup_recovery_instructions: bool,
204    include_persist_answer_instructions: bool,
205    has_native_mcp: bool,
206) -> String {
207    let purpose = purpose.unwrap_or(NativePurpose::ContextEnrichment);
208    let sections = ir
209        .components
210        .iter()
211        .filter(|node| node.realization_kind == RealizationKind::Skill)
212        .map(|node| {
213            let fragment = match purpose {
214                NativePurpose::Analysis => native_analysis_prompt_fragment(&node.prompt_fragment),
215                NativePurpose::ContextEnrichment => {
216                    native_context_enrichment_prompt_fragment(&node.prompt_fragment)
217                }
218                NativePurpose::Setup => node.prompt_fragment.clone(),
219            };
220            match &node.brief {
221                Some(brief) => format!("{brief}\n\n{fragment}"),
222                None => fragment,
223            }
224        })
225        .collect::<Vec<_>>()
226        .join("\n\n");
227    let scope = match purpose {
228        NativePurpose::Setup => "Operate only within the server-created bootstrap scope. Do not adopt, discover, or switch to an existing project.",
229        NativePurpose::Analysis | NativePurpose::ContextEnrichment => "Operate only within the server-bound project scope. Do not change cwd or follow a caller-supplied project path.",
230    };
231    let safety = match purpose {
232        NativePurpose::ContextEnrichment => "Do not write files, invoke a headless runner, start an app server, or use `codex exec`. Do not invoke or simulate `apply_enrichment`. An apply remains a native interactive human-approval workflow with dry-run, scoped authorization, validation/build, and rollback checks.",
233        NativePurpose::Analysis => "Do not read credentials or expose raw material. Do not invoke a headless runner, start an app server, or use `codex exec`.",
234        NativePurpose::Setup => "Do not read credentials into output, start an app server, or use `codex exec`. The host owns all session lifecycle and any subsequent project binding.",
235    };
236    let mut skill = format!(
237        "---\nname: {}\ndescription: {}\n---\n\n{}\n\n# GenBI {}\n\n{}\n\n{}\n\n{}\n",
238        purpose.codex_skill(),
239        purpose.codex_description(),
240        marker,
241        purpose.as_str(),
242        scope,
243        sections,
244        safety
245    );
246    if include_setup_recovery_instructions {
247        skill.push('\n');
248        skill.push_str(setup_recovery_instructions());
249    }
250    if purpose == NativePurpose::Setup {
251        skill.push('\n');
252        skill.push_str(&setup_bootstrap_authority_instructions());
253    }
254    if include_persist_answer_instructions {
255        skill.push('\n');
256        skill.push_str(native_answer_persistence_instructions());
257    }
258    if purpose == NativePurpose::Analysis {
259        skill.push('\n');
260        skill.push_str(native_analysis_terminal_presentation_instructions());
261    }
262    if purpose == NativePurpose::ContextEnrichment {
263        skill.push('\n');
264        skill.push_str(native_context_enrichment_terminal_presentation_instructions());
265    }
266    if purpose == NativePurpose::Analysis
267        && has_native_mcp
268        && ir.components.iter().any(is_dashboard_component)
269    {
270        skill.push('\n');
271        skill.push_str(native_dashboard_save_instructions());
272    }
273    skill
274}
275
276fn is_dashboard_component(node: &crate::ir::ComponentNode) -> bool {
277    node.required_capabilities
278        .iter()
279        .any(|capability| capability == "genbi_build")
280        && node
281            .required_capabilities
282            .iter()
283            .any(|capability| capability == "artifact_write")
284}
285
286/// The host persistence contract is derived from the component's declared
287/// render blocks, so this target cannot introduce a Codex-only answer shape.
288fn is_persisted_answer_component(node: &crate::ir::ComponentNode) -> bool {
289    let blocks = &node.effect.render_blocks;
290    let table = blocks
291        .iter()
292        .filter(|block| block.block_type == "table")
293        .count();
294    let definition = blocks
295        .iter()
296        .filter(|block| block.block_type == "definition")
297        .count();
298    table == 1
299        && definition <= 1
300        && blocks.len() == table + definition
301        && blocks
302            .iter()
303            .find(|block| block.block_type == "table")
304            .is_some_and(|block| {
305                block
306                    .fields
307                    .get("columns")
308                    .is_some_and(|field| field == "string[]")
309                    && block
310                        .fields
311                        .get("rows")
312                        .is_some_and(|field| field == "row[]")
313            })
314        && blocks
315            .iter()
316            .filter(|block| block.block_type == "definition")
317            .all(|block| {
318                block
319                    .fields
320                    .get("sql")
321                    .is_some_and(|field| field == "string")
322                    && block
323                        .fields
324                        .get("source_tables")
325                        .is_some_and(|field| field == "string[]")
326                    && block
327                        .fields
328                        .get("filters")
329                        .is_some_and(|field| field == "string[]")
330            })
331}
332
333fn build_agents(marker: &str, purpose: Option<NativePurpose>) -> String {
334    let purpose = purpose.unwrap_or(NativePurpose::ContextEnrichment);
335    format!("{}\n# Warble native {}\n\nUse the `${}` skill for this server-selected purpose. The caller owns the PTY, process, prompt injection, transcript, and session lifecycle.\n", marker, purpose.as_str(), purpose.codex_skill())
336}
337
338fn build_run(marker: &str, skill_name: &str, purpose: Option<NativePurpose>) -> String {
339    let purpose = purpose.unwrap_or(NativePurpose::ContextEnrichment);
340    format!("{}\n# Native Codex {} session\n\nRead `.warble/interactive-launch.json`, then start the native `codex` TUI in its canonical cwd. Codex discovers `AGENTS.md` and `.agents/skills/{}/SKILL.md` from that repository scope. The caller owns the PTY, prompt injection, process, transcript, and session lifecycle.\n", marker, purpose.as_str(), skill_name)
341}