Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions crates/nebula-core/src/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Whether the picker and presets offer this harness.
#[serde(default = "default_enabled")]
pub enabled: bool,
Expand Down Expand Up @@ -373,6 +376,7 @@ pub fn builtin(id: &str) -> Option<HarnessDescriptor> {
id: id.trim().to_string(),
label: String::new(),
program: String::new(),
launch_args: Vec::new(),
enabled: true,
model: ModelSpec::default(),
effort: EffortSpec::default(),
Expand Down Expand Up @@ -419,6 +423,9 @@ pub fn builtin(id: &str) -> Option<HarnessDescriptor> {
"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(),
Expand Down Expand Up @@ -669,6 +676,8 @@ pub struct HarnessOverride {
pub effort_offered: Option<bool>,
#[serde(default, skip_serializing_if = "Clearable::is_keep")]
pub permissions_flag: Clearable<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub launch_args: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Clearable::is_keep")]
pub prompt_flag: Clearable<String>,
#[serde(default, skip_serializing_if = "Clearable::is_keep")]
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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!({
Expand Down
59 changes: 57 additions & 2 deletions crates/nebula-daemon/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand Down Expand Up @@ -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).
Expand All @@ -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!(
Expand Down Expand Up @@ -1108,6 +1151,7 @@ mod tests {
(
"codex".into(),
vec![
"--no-daemon".to_string(),
"resume".to_string(),
"sid-2".to_string(),
"--cd".to_string(),
Expand Down Expand Up @@ -1174,6 +1218,7 @@ mod tests {
(
"codex".into(),
vec![
"--no-daemon".to_string(),
"--yolo".to_string(),
"--model".to_string(),
"gpt-5.5".to_string(),
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -1643,6 +1696,7 @@ mod tests {
)
.1,
vec![
"--no-daemon",
"--yolo",
"--model",
"gpt-5.5",
Expand Down Expand Up @@ -1797,6 +1851,7 @@ mod tests {
assert_eq!(
args,
vec![
"--no-daemon",
"resume",
"sid",
"--cd",
Expand Down
9 changes: 9 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down