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