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