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
63 changes: 63 additions & 0 deletions server/src/agents/launch_plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ pub(crate) fn create_agent_session_params_for_project(
.or_else(|| read_text_from_map(&launch_settings, "icon"));
let configured_command = read_text_from_map(&agent_config, "command")
.or_else(|| read_text_from_map(&launch_settings, "agentCommand"));
let configured_command = apply_requested_agent_model(
&agent_id,
&agent_config,
&launch_settings,
params,
configured_command,
)?;
if let Some(command) = configured_command.as_ref() {
runtime_settings
.entry("accountBaseCommand")
Expand Down Expand Up @@ -254,6 +261,36 @@ pub(crate) fn project_agent_session_default_title(project: &Value, session: &Val
)
}

/// CDXC:AgentProviders 2026-09-17 DECISION:
/// User: an agent spawning another agent sets that worker's model and effort for the session only, for Claude and Codex only, and a resumed worker keeps them.
/// Typing `/model` or `/effort` into Claude Code saves the choice as the default for every new session, so the choice travels as launch flags instead.
/// The flags live in the session's saved base command, which resume, fork and account wrapping all rebuild from.
/// SEE-ALSO: server/src/ghostex_cli/actions.rs (create-agent), server/src/ghostex_cli/board.rs and server/src/board_start_work.rs (board start-work).
fn apply_requested_agent_model(
agent_id: &str,
agent_config: &Map<String, Value>,
launch_settings: &Map<String, Value>,
params: &Map<String, Value>,
command: Option<String>,
) -> Result<Option<String>, DomainStateError> {
let model = requested_agent_model_option(params, "agentModel")?;
let effort = requested_agent_model_option(params, "agentEffort")?;
if model.is_none() && effort.is_none() {
return Ok(command);
}
let family = resume_agent_family_id(Some(agent_id.to_string()), agent_config, launch_settings)
.filter(|family| matches!(family.as_str(), "claude" | "codex"))
.ok_or_else(|| {
DomainStateError::bad_request(
"A launch model or effort can only be set for Claude and Codex agents.",
)
})?;
let base = command
.or_else(|| default_agent_command(&family).map(str::to_string))
.unwrap_or_else(|| family.clone());
with_agent_model_options(&base, &family, model.as_deref(), effort.as_deref()).map(Some)
}

pub(crate) fn create_agent_session_default_title(
agent_name: Option<&str>,
agent_id: Option<&str>,
Expand Down Expand Up @@ -425,3 +462,29 @@ pub(crate) fn resolve_agent_launch_command(
accept_all_mode == Some("disabled"),
)
}

/// Validate supplied launch options before an empty or non-string value can be mistaken for an omitted option.
pub(crate) fn requested_agent_model_option(
params: &Map<String, Value>,
key: &str,
) -> Result<Option<String>, DomainStateError> {
let value = match params.get(key) {
None | Some(Value::Null) => return Ok(None),
Some(Value::String(value)) if !value.trim().is_empty() => value.trim(),
_ => {
return Err(DomainStateError::bad_request(format!(
"{key} needs a non-empty string value."
)))
}
};
if value.len() > 160
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"-._[]():/".contains(&byte))
{
return Err(DomainStateError::bad_request(format!(
"\"{value}\" is not a valid model or effort."
)));
}
Ok(Some(value.to_string()))
}
149 changes: 149 additions & 0 deletions server/src/agents/session_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,152 @@ fn option_takes_value(agent: &str, word: &str) -> bool {
_ => false,
}
}

/// CDXC:AgentProviders 2026-09-17 WHY:
/// Codex rejects a repeated `--model`, and a custom agent command may already pin one, so a launch-time choice replaces the existing option instead of appending a second copy.
/// Values of other options are skipped so a quoted instruction such as `--append-system-prompt '--model'` stays untouched.
pub(crate) fn with_agent_model_options(
command: &str,
agent: &str,
model: Option<&str>,
effort: Option<&str>,
) -> Result<String, DomainStateError> {
validate_model_option_command(command)?;
let mut words = Vec::new();
let mut offset = 0;
while !command[offset..].trim().is_empty() {
let word = command_word(command, offset).ok_or_else(|| {
DomainStateError::bad_request("The agent command has unfinished shell quoting.")
})?;
offset = word.1;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
words.push(word);
}
let codex_effort = |value: &str| value.starts_with("model_reasoning_effort=");
let mut removed = Vec::new();
let mut index = 0;
while index < words.len() {
let (start, _, word) = &words[index];
let is_flag = word.starts_with('-');
Comment thread
maddada marked this conversation as resolved.
let next_value = words.get(index + 1).map(|(_, _, value)| value.as_str());
let (remove, takes_value) = match (agent, word.as_str()) {
_ if !is_flag => (false, false),
("claude" | "codex", "--model") | ("codex", "-m") if model.is_some() => (true, true),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High agents/session_command.rs:198

A saved command such as codex "$CODEX_MODEL_FLAG" old with CODEX_MODEL_FLAG=--model appends a second --model, leaving conflicting model selections in the launch. Option matching uses the unexpanded word before shell parameter expansion, so it misses selectors supplied through environment variables; expand parameters before recognizing options or reject parameter-expanded option tokens.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @server/src/agents/session_command.rs around line 198:

A saved command such as `codex "$CODEX_MODEL_FLAG" old` with `CODEX_MODEL_FLAG=--model` appends a second `--model`, leaving conflicting model selections in the launch. Option matching uses the unexpanded `word` before shell parameter expansion, so it misses selectors supplied through environment variables; expand parameters before recognizing options or reject parameter-expanded option tokens.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High agents/session_command.rs:198

with_agent_model_options appends a second --model instead of replacing the existing selector for valid commands such as codex $'--model' old, so the requested model override is not applied reliably. The word scanner does not decode dollar-single-quoted words and produces $--model, which misses the replacement branch at line 198; update the scanner to parse this shell quoting form before matching option names.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @server/src/agents/session_command.rs around line 198:

`with_agent_model_options` appends a second `--model` instead of replacing the existing selector for valid commands such as `codex $'--model' old`, so the requested model override is not applied reliably. The word scanner does not decode dollar-single-quoted words and produces `$--model`, which misses the replacement branch at line 198; update the scanner to parse this shell quoting form before matching option names.

("claude" | "codex", value) if model.is_some() && value.starts_with("--model=") => {
(true, false)
}
("claude", "--effort") if effort.is_some() => (true, true),
("claude", value) if effort.is_some() && value.starts_with("--effort=") => {
(true, false)
}
("codex", "-c" | "--config")
if effort.is_some() && next_value.is_some_and(codex_effort) =>
{
(true, true)
}
("codex", value)
if effort.is_some()
&& value.strip_prefix("--config=").is_some_and(codex_effort) =>
{
(true, false)
}
(_, value) => (false, option_takes_value(agent, value)),
};
if remove
&& takes_value
&& words
.get(index + 1)
.is_none_or(|(start, end, _)| command[*start..*end].starts_with('-'))
{
return Err(DomainStateError::bad_request(format!(
"The agent command option {word} needs a value before a model or effort override can be applied."
)));
}
let last = if takes_value && index + 1 < words.len() {
index + 1
} else {
index
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if remove {
removed.push((command[..*start].trim_end().len(), words[last].1));
}
index = last + 1;
}
let mut result = command.to_string();
for (start, end) in removed.into_iter().rev() {
result.replace_range(start..end, "");
}
let mut result = result.trim().to_string();
if let Some(model) = model {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
result.push_str(&format!(" --model {}", shell_word(model)));
}
if let Some(effort) = effort {
match agent {
"codex" => result.push_str(&format!(
" -c {}",
shell_word(&format!("model_reasoning_effort={effort}"))
)),
_ => result.push_str(&format!(" --effort {}", shell_word(effort))),
}
}
Ok(result)
}

/// Model ids such as `opus[1m]` carry shell glob characters, so only plain words stay unquoted.
fn shell_word(value: &str) -> String {
if value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"-._:/=".contains(&byte))
{
value.to_string()
} else {
super::quote_shell_arg(value)
}
}

/// CDXC:AgentProviders 2026-09-18 WHY:
/// Appending selectors to a shell list can pass them to a later command, and a trailing comment can swallow them entirely. Only rewrite a single invocation; quoted or escaped prompt text remains literal.
fn validate_model_option_command(command: &str) -> Result<(), DomainStateError> {
let unsupported_command = || {
DomainStateError::bad_request(
"Model and effort overrides require a single agent command without shell operators, command substitutions, comments, or line continuations.",
)
};
let mut quote = None;
let mut escaped = false;
let mut word_start = true;
let mut chars = command.trim().chars().peekable();
while let Some(ch) = chars.next() {
if escaped {
if matches!(ch, '\n' | '\r') {
return Err(unsupported_command());
}
escaped = false;
continue;
}
if quote == Some('\'') {
if ch == '\'' {
quote = None;
}
continue;
}
if ch == '\\' {
escaped = true;
word_start = false;
continue;
}
let shell_expansion = ch == '`' || (ch == '$' && chars.peek() == Some(&'('));
let shell_boundary = quote.is_none()
&& (matches!(ch, ';' | '&' | '|' | '<' | '>' | '(' | ')' | '\n' | '\r')
|| (ch == '#' && word_start));
if shell_expansion || shell_boundary {
return Err(unsupported_command());
}
if quote == Some(ch) {
quote = None;
} else if quote.is_none() && matches!(ch, '\'' | '"') {
quote = Some(ch);
}
word_start = quote.is_none() && ch.is_whitespace();
}
Ok(())
}
8 changes: 8 additions & 0 deletions server/src/board_start_work.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use serde_json::{json, Map, Value};

use crate::agents::{
apply_created_session_identity, create_agent_session_params_for_project, read_agent_settings,
requested_agent_model_option,
};
use crate::domain::{DomainRepository, DomainStateError};
use crate::presentation::list_previous_sessions;
Expand Down Expand Up @@ -47,6 +48,8 @@ pub fn start_board_work(
params: &Map<String, Value>,
bd_executable_path: &str,
) -> Result<StartBoardWorkOutcome, DomainStateError> {
let model = requested_agent_model_option(params, "agentModel")?;
let effort = requested_agent_model_option(params, "agentEffort")?;
let bead_id = read_trimmed(params, "beadId")
.ok_or_else(|| DomainStateError::bad_request("startBoardWork requires a bead id."))?;
let projects = repository.list_projects()?;
Expand Down Expand Up @@ -133,6 +136,11 @@ pub fn start_board_work(
Value::String("workspace".to_string()),
);
create_params.insert("requireLaunchCommand".to_string(), Value::Bool(true));
for (key, value) in [("agentModel", model), ("agentEffort", effort)] {
if let Some(value) = value {
create_params.insert(key.to_string(), Value::String(value));
}
}
let mut launch_settings = Map::new();
if let Some(command) = agent_button.and_then(|button| button.get("command")) {
launch_settings.insert("agentCommand".to_string(), command.clone());
Expand Down
15 changes: 15 additions & 0 deletions server/src/ghostex_cli/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -916,6 +916,19 @@ fn create_gxserver_agent_session(payload: &Value, flags: &Flags) -> CliResult<Va
let mut params = Map::new();
params.insert("agentId".to_string(), json!(agent_id));
params.insert("projectId".to_string(), json!(project_id));
for (key, flag) in [("agentModel", "--model"), ("agentEffort", "--effort")] {
match payload.get(key) {
None | Some(Value::Null) => {}
Some(Value::String(value)) if !value.trim().is_empty() => {
params.insert(key.to_string(), json!(value));
}
Some(_) => {
return Err(CliError::Other(format!(
"create-agent {flag} needs a value."
)))
}
}
}
// CDXC:SessionChat 2026-09-09 SEE-ALSO:
// Mobile uses --defer-start to open the durable draft immediately; its background attach owns provider startup.
if flags.truthy("deferStart") {
Expand Down Expand Up @@ -1568,6 +1581,8 @@ fn parse_agent(rest: &[String], flags: &Flags) -> Value {
flag_json(flags, "firstInputDraft"),
);
set_or_remove(&mut map, "groupId", flag_json(flags, "groupId"));
set_or_remove(&mut map, "agentModel", flag_json(flags, "model"));
set_or_remove(&mut map, "agentEffort", flag_json(flags, "effort"));
Value::Object(map)
}

Expand Down
11 changes: 11 additions & 0 deletions server/src/ghostex_cli/board.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,17 @@ fn start_work_command(args: &[String]) -> CliResult<()> {
{
payload.insert("agent".to_string(), Value::String(agent));
}
for (flag, key) in [("model", "agentModel"), ("effort", "agentEffort")] {
if parsed.flags.0.contains_key(flag) {
let value = parsed.flags.string_value(flag).unwrap_or_default().trim();
if value.is_empty() {
return Err(CliError::Other(format!(
"board start-work --{flag} needs a value."
)));
}
payload.insert(key.to_string(), Value::String(value.to_string()));
}
}
if let Some(project_id) = parsed
.flags
.text("projectId")
Expand Down
13 changes: 8 additions & 5 deletions server/src/ghostex_cli/usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,11 +147,11 @@ pub fn usage() -> String {
"Create a Quick chat workspace with its first terminal session",
),
format_help_command(
"create-agent <agentId> --project-id id [--group-id id] [--first-input-draft text] [--defer-start]",
"Create and start a configured agent session; --first-input-draft stages text in its input without sending",
"create-agent <agentId> --project-id id [--group-id id] [--model m] [--effort e] [--first-input-draft text] [--defer-start]",
"Create and start a configured agent session; --model/--effort (Claude, Codex) apply to this session only; --first-input-draft stages text in its input without sending",
),
format_help_command(
"board start-work <bead-id> [--agent id] [--project-path path|--project-id id] [--json]",
"board start-work <bead-id> [--agent id] [--model m] [--effort e] [--project-path path|--project-id id] [--json]",
"Dispatch a Project Board bead: reuse its usable linked session or create the worker",
),
format_help_command(
Expand Down Expand Up @@ -784,7 +784,7 @@ Inspect:
pub fn board_usage() -> String {
let commands = [
format_help_command(
"board start-work <bead-id> [--agent id] [--project-path path|--project-id id] [--json]",
"board start-work <bead-id> [--agent id] [--model m] [--effort e] [--project-path path|--project-id id] [--json]",
"Dispatch a Project Board bead through gxserver",
),
format_help_command(
Expand All @@ -802,7 +802,7 @@ pub fn board_usage() -> String {
"Ghostex Project Board - dispatch bead work through gxserver

Usage:
ghostex board start-work <bead-id> [--agent <agentId>] [--project-path <path>|--project-id <id>] [--json]
ghostex board start-work <bead-id> [--agent <agentId>] [--model <model>] [--effort <level>] [--project-path <path>|--project-id <id>] [--json]
ghostex board associate <bead-id> [--session-id <alias|id|title>] [--project-id <id>] [--json]
gx board start-work <bead-id>
gx board associate <bead-id>
Expand All @@ -817,6 +817,9 @@ Behavior:
is returned as {{ \"projectId\": ..., \"sessionId\": ..., \"created\": false }} instead of creating a second worker.
Without --agent, the bead assignee is matched case-insensitively against configured agents,
falling back to the default prompt agent.
--model and --effort start a new Claude or Codex worker on that model and effort for this session
only; your default model is unchanged, and a resumed worker keeps them. A reused worker keeps
whatever it already runs.
Pass --project-path <repo> (or --project-id) to start the worker in the project the card is
about; the bead is still looked up on that project's board. Without either, the worker starts
in the project whose own path is the Beads directory - the board itself - never in a sibling
Expand Down
7 changes: 7 additions & 0 deletions skills/ghostex-help/references/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,13 @@ Cross-agent orchestration also works through the `$ghostex-cli` skill. For
`ghostex send-message <selector> "<text>"`, read output with
`ghostex read-text` or `ghostex read-session-chat`, and wait with
`ghostex wait-for-text`.
To pick the worker's model and effort, add `--model <model> --effort <level>`
to `create-agent` or `board start-work` (Claude and Codex). The choice
applies to that session only, survives a resume, and leaves your default
model unchanged. For `board start-work`, these flags apply only when a new
worker is created; a reused linked worker keeps its existing model and effort.
Model and effort overrides require a single agent launch command, without
shell operators, command substitutions, comments, or line continuations.
3. The optional Fable 5.6 Orchestration skill (`$ghostex-fable-56-orchestration`)
packages a plan-with-Claude, implement-with-Codex, verify-with-Claude
pipeline.
Expand Down
9 changes: 8 additions & 1 deletion skills/ghostex-manage-beads/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ not for an agent that is already doing the bead's work.
To dispatch a bead through Ghostex, run:

```bash
gx board start-work <bead-id> [--agent <agentId>] [--project-path <path>|--project-id <id>] [--json]
gx board start-work <bead-id> [--agent <agentId>] [--model <model>] [--effort <level>] [--project-path <path>|--project-id <id>] [--json]
```

- **The command is the dispatch.** It creates and starts the visible worker
Expand All @@ -102,6 +102,13 @@ gx board start-work <bead-id> [--agent <agentId>] [--project-path <path>|--proje
worker session yourself — it is not a preparation step before separately
starting another worker. Calling it and then starting your own worker puts
two workers on the same card.
- **Pick the worker's model with `--model` and `--effort`** (Claude and Codex
workers only, when `start-work` creates a new worker). A reused linked worker
keeps its existing model and effort. The choice applies to the new worker
session only, a resume keeps it, and the user's default model is untouched.
Ghostex releases whose
`gx board --help` does not list them ignore the flags, and the worker starts
on the default model.
- **Already working the bead? Do not call it.** An agent that is itself doing
the bead's work must not run the command; that would create an additional
worker for work that is already underway.
Expand Down
Loading