diff --git a/crates/nebula-core/src/harness.rs b/crates/nebula-core/src/harness.rs index 9b7006ad..5b4b2175 100644 --- a/crates/nebula-core/src/harness.rs +++ b/crates/nebula-core/src/harness.rs @@ -160,6 +160,9 @@ pub struct HarnessDescriptor { pub label: String, /// The CLI nebula launches, resolved on PATH through the login shell. pub program: String, + /// Arguments before any resume subcommand or per-session options. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub launch_args: Vec, /// Whether the picker and presets offer this harness. #[serde(default = "default_enabled")] pub enabled: bool, @@ -373,6 +376,7 @@ pub fn builtin(id: &str) -> Option { id: id.trim().to_string(), label: String::new(), program: String::new(), + launch_args: Vec::new(), enabled: true, model: ModelSpec::default(), effort: EffortSpec::default(), @@ -419,6 +423,9 @@ pub fn builtin(id: &str) -> Option { "codex" => HarnessDescriptor { label: "Codex".into(), program: "codex".into(), + // Hooks and tool shells must inherit this PTY's NEBULA_* + // environment, not a shared server's startup environment. + launch_args: vec!["--no-daemon".into()], model: ModelSpec { flag: Some("--model".into()), default: default_model_choice(), @@ -669,6 +676,8 @@ pub struct HarnessOverride { pub effort_offered: Option, #[serde(default, skip_serializing_if = "Clearable::is_keep")] pub permissions_flag: Clearable, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub launch_args: Option>, #[serde(default, skip_serializing_if = "Clearable::is_keep")] pub prompt_flag: Clearable, #[serde(default, skip_serializing_if = "Clearable::is_keep")] @@ -727,6 +736,9 @@ impl HarnessDescriptor { self.effort.offered = offered; } over.permissions_flag.apply_to(&mut self.permissions_flag); + if let Some(args) = &over.launch_args { + self.launch_args = args.clone(); + } over.prompt_flag.apply_to(&mut self.prompt_flag); over.resume_flag.apply_to(&mut self.resume.flag); over.resume_subcommand.apply_to(&mut self.resume.subcommand); @@ -812,6 +824,7 @@ impl CustomHarness { id: self.id.clone(), label: self.label.clone(), program: self.program.clone(), + launch_args: Vec::new(), enabled: self.enabled, model: ModelSpec { flag: Some(self.model_flag.clone()), @@ -897,6 +910,7 @@ pub fn registry( id: id.clone(), label: String::new(), program: String::new(), + launch_args: Vec::new(), enabled: true, model: ModelSpec::default(), effort: EffortSpec::default(), @@ -996,6 +1010,7 @@ mod tests { assert_eq!(claude.resume.flag.as_deref(), Some("--resume")); assert!(claude.relocation_prompt); let codex = builtin("codex").unwrap(); + assert_eq!(codex.launch_args, ["--no-daemon"]); assert_eq!(codex.model.catalog, Some(HarnessCatalog::Codex)); assert_eq!(HarnessCatalog::parse("codex"), Some(HarnessCatalog::Codex)); assert_eq!(HarnessCatalog::Codex.as_str(), "codex"); @@ -1028,6 +1043,31 @@ mod tests { assert!(!opencode.claude_like()); } + #[test] + fn launch_args_follow_registry_overrides_and_can_be_cleared() { + let mut codex = builtin("codex").unwrap(); + codex.apply(&HarnessOverride::default()); + assert_eq!(codex.launch_args, ["--no-daemon"]); + let over: HarnessOverride = serde_json::from_value(serde_json::json!({ + "launch_args": ["--no-daemon", "--no-alt-screen"] + })) + .unwrap(); + codex.apply(&over); + assert_eq!(codex.launch_args, ["--no-daemon", "--no-alt-screen"]); + let encoded = serde_json::to_value(&over).unwrap(); + assert_eq!(encoded["launch_args"], serde_json::json!(codex.launch_args)); + let clear: HarnessOverride = serde_json::from_value(serde_json::json!({ + "launch_args": [] + })) + .unwrap(); + codex.apply(&clear); + assert!(codex.launch_args.is_empty()); + // Speaking Codex's hooks alone does not promise its CLI flags. + let mut custom = custom("wrapper"); + custom.hooks = Some("codex".into()); + assert!(custom.as_descriptor().launch_args.is_empty()); + } + #[test] fn overrides_apply_field_by_field_with_null_clearing() { let over: HarnessOverride = serde_json::from_value(serde_json::json!({ diff --git a/crates/nebula-daemon/src/registry.rs b/crates/nebula-daemon/src/registry.rs index 8fdb7651..c8a4fb10 100644 --- a/crates/nebula-daemon/src/registry.rs +++ b/crates/nebula-daemon/src/registry.rs @@ -552,6 +552,10 @@ fn agent_spawn_command_with( } _ => (Vec::new(), false), }; + // Global launch options precede subcommands too: Codex's --no-daemon + // keeps hooks and tools in this session's NEBULA_* environment on + // fresh starts, resumes, restarts and worktree relocations alike. + args.splice(0..0, harness.launch_args.iter().cloned()); if let Some(flag) = harness.permissions_flag.as_deref() { args.push(flag.to_string()); } @@ -1028,6 +1032,41 @@ mod tests { assert_eq!(claude_transcript_exists(&[missing], "sid-1"), None); } + #[test] + fn codex_sessions_keep_the_nebula_environment_on_cold_launch_and_resume() { + let all = test_registry(); + let codex = test_harness(&all, AgentKind::Codex); + // The shared Codex server can predate this PTY and has none of its + // NEBULA_* variables. Both hooks and `nebula rename` need this + // invocation's environment, including when no model/effort override + // happens to force Codex into embedded mode on its own. + for sid in [None, Some("sid")] { + for prompt in [None, Some("fix the login redirect")] { + let (_, args, resumed) = agent_spawn_command_with( + &codex, + sid, + Some(Path::new(TEST_CWD)), + None, + None, + None, + prompt, + None, + true, + ); + assert_eq!(resumed, sid.is_some()); + assert_eq!(args.first().map(String::as_str), Some("--no-daemon")); + if let Some(prompt) = prompt { + assert_eq!(args.last().map(String::as_str), Some(prompt)); + } + } + } + // A test stand-in is a complete command, never a Codex invocation. + assert_eq!( + agent_spawn_command(AgentKind::Codex, None, None, None, Some("/bin/cat")), + ("/bin/cat".into(), Vec::new(), false) + ); + } + #[test] fn spawn_command_per_kind_resume_shapes() { // Fresh sessions: bare CLI (Claude plus its system-prompt guidance). @@ -1042,7 +1081,11 @@ mod tests { // Codex/cursor always run in skip-permissions mode. assert_eq!( agent_spawn_command(AgentKind::Codex, None, None, None, None), - ("codex".into(), vec!["--yolo".to_string()], false) + ( + "codex".into(), + vec!["--no-daemon".to_string(), "--yolo".to_string()], + false + ) ); // Cursor's agent CLI is `cursor-agent`, not `cursor` (the editor). assert_eq!( @@ -1108,6 +1151,7 @@ mod tests { ( "codex".into(), vec![ + "--no-daemon".to_string(), "resume".to_string(), "sid-2".to_string(), "--cd".to_string(), @@ -1174,6 +1218,7 @@ mod tests { ( "codex".into(), vec![ + "--no-daemon".to_string(), "--yolo".to_string(), "--model".to_string(), "gpt-5.5".to_string(), @@ -1590,7 +1635,15 @@ mod tests { true, ) .1, - vec!["resume", "sid", "--cd", TEST_CWD, "--yolo", "carry on"] + vec![ + "--no-daemon", + "resume", + "sid", + "--cd", + TEST_CWD, + "--yolo", + "carry on" + ] ); assert_eq!( agent_spawn_command_with( @@ -1643,6 +1696,7 @@ mod tests { ) .1, vec![ + "--no-daemon", "--yolo", "--model", "gpt-5.5", @@ -1797,6 +1851,7 @@ mod tests { assert_eq!( args, vec![ + "--no-daemon", "resume", "sid", "--cd", diff --git a/docs/configuration.md b/docs/configuration.md index f6bbe41e..0e7cc461 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -180,6 +180,15 @@ including ones a later nebula adds. Like every other object key, a `harnesses` m `config.local.json` replaces the whole map from `config.json` rather than merging per harness, so keep machine-specific overrides in one layer. +`launch_args` is an array of arguments placed before resume subcommands and session options. +Codex defaults to `["--no-daemon"]`: its hooks and tool shells need the current session's +`NEBULA_*` environment for status updates and automatic naming. A shared Codex background server +keeps its own startup environment, which can belong to another session or have no Nebula context. +Other harnesses default to `[]`; Codex-compatible wrappers can opt in explicitly. Setting +`"codex": {"launch_args": []}` removes the flag for older CLIs, with the risk of missing or +misdirected hooks when a shared server is used. Hook trust review still applies; Nebula does not +bypass it. + Grok Build is enabled by default and launches `grok`. Its Enabled toggle is stored under `harnesses.grok.enabled`. Set `harnesses.grok.model_default` and `effort_default` in config.json to pass `--model` and `--reasoning-effort`; unset values use the CLI defaults. The picker offers