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 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
284fn 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}