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