1use 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 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
286fn 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}