Skip to content
4 changes: 4 additions & 0 deletions docs-site/docs/en/api-task-trigger.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ Hard constraints (all validated; violations return 400):
- **`envelope.trusted` must be `false`**. This is an injection-defense design: `trusted:false` declares "the envelope content is untrusted external data", so the daemon wraps it as an untrusted event and does not execute instructions embedded inside it. Put what you actually want the bot to do in the top-level `instruction` (the trusted directive), not in the envelope.
- **Omitting `chatId`** requires `options` to contain either `waitForFinalOutput` or `asyncReturnSessionId`, else `target_required`.
- **`options.timeoutMs` range `[1000, 300000]`** (1s–5min); out of range returns 400. Defaults to 120000.
- **`options.model`** / **`options.reasoningEffort`** (optional, **codex / codex-app bots only**): override the model and reasoning level for this trigger.
- `model`: a codex model id (≤200 chars); `reasoningEffort`: `low` / `medium` / `high` / `xhigh` (passed to codex verbatim — no downgrade).
- **Fresh-session only**: frozen only when this trigger **creates a new session**; a fold-in to an existing worker does not rewrite it.
- **Scoped to the codex family**: ignored when the target bot is not codex/codex-app (never changes a Claude/Gemini/CoCo bot's model).

### Sync mode (waitForFinalOutput)

Expand Down
4 changes: 4 additions & 0 deletions docs-site/docs/zh/api-task-trigger.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@
- **`envelope.trusted` 必须是 `false`**。这是防注入设计:`trusted:false` 声明「以下 envelope 内容是不可信外部数据」,daemon 才会把它包成 untrusted event、不执行里面夹带的指令。你要机器人真正执行的东西放在顶层 `instruction`(可信指令),不要放进 envelope。
- **不传 `chatId`** 时,`options` 必须含 `waitForFinalOutput` 或 `asyncReturnSessionId` 之一,否则报 `target_required`。
- **`options.timeoutMs` 范围 `[1000, 300000]`**(1 秒 ~ 5 分钟),越界报 400。不传默认 120000。
- **`options.model`** / **`options.reasoningEffort`**(可选,**仅对 codex / codex-app 机器人生效**):按本次触发覆盖模型与推理档位。
- `model`:codex 模型 id(≤200 字符);`reasoningEffort`:`low` / `medium` / `high` / `xhigh`(原样透传给 codex,不做降级)。
- **仅新建会话生效**:只在这次触发**创建新会话**时冻结;折叠进已有 worker 的续轮不改写。
- **作用域收窄到 codex 家族**:目标机器人不是 codex/codex-app 时,这两个字段被忽略(不会改动 Claude/Gemini/CoCo 等的模型)。

### 同步模式(waitForFinalOutput)

Expand Down
6 changes: 5 additions & 1 deletion src/adapters/cli/codex-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export function createCodexAppAdapter(pathOverride?: string): CliAdapter {
return [(cachedCodexBin ??= resolveCommand(rawCodexBin))];
},

buildArgs({ sessionId, resume, resumeSessionId, workingDir, botName, botOpenId, locale }) {
buildArgs({ sessionId, resume, resumeSessionId, workingDir, botName, botOpenId, locale, model, reasoningEffort }) {
const args = [
runnerPath(),
'--session-id', sessionId,
Expand All @@ -54,6 +54,10 @@ export function createCodexAppAdapter(pathOverride?: string): CliAdapter {
pushOpt(args, '--bot-name', botName);
pushOpt(args, '--bot-open-id', botOpenId);
pushOpt(args, '--locale', locale);
// Per-turn overrides (async trigger API). The runner injects them into the
// app-server thread/start (model + config.model_reasoning_effort).
pushOpt(args, '--model', model && model.trim() ? model.trim() : undefined);
pushOpt(args, '--reasoning-effort', reasoningEffort);
return args;
},

Expand Down
8 changes: 7 additions & 1 deletion src/adapters/cli/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export function createCodexAdapter(pathOverride?: string): CliAdapter {
authPaths: ['~/.codex'],
get resolvedBin(): string { return (cachedBin ??= resolveCommand(rawBin)); },

buildArgs({ sessionId, resume, resumeSessionId, workingDir, model, disableCliBypass, readIsolation, remoteWsUrl, remoteThreadId }) {
buildArgs({ sessionId, resume, resumeSessionId, workingDir, model, reasoningEffort, disableCliBypass, readIsolation, remoteWsUrl, remoteThreadId }) {
// Hybrid RPC input mode: attach this TUI to the botmux-owned app-server
// thread. User input is delivered out-of-band via JSON-RPC (turn/start,
// see codex-rpc-engine + worker), so the pane is a pure viewer — no paste
Expand Down Expand Up @@ -202,6 +202,12 @@ export function createCodexAdapter(pathOverride?: string): CliAdapter {
// Codex 接受 `--model <id>` / `-m <id>`,写全名最稳,错的会在 codex 自己启动时报。
baseArgs.push('--model', model.trim());
}
if (reasoningEffort) {
// Per-turn reasoning effort → codex model_reasoning_effort(进程级 -c 覆盖,
// 不动用户全局 config)。Codex 0.145 实测接受 low/medium/high/xhigh(xhigh
// 原样回显),故原样透传,不做降级——收敛会静默改变用户请求的档位。
baseArgs.push('-c', `model_reasoning_effort=${JSON.stringify(reasoningEffort)}`);
}
// Codex app-server can keep its own cwd at $HOME; -C pins fresh agent roots.
// NOTE: canonicalization of workingDir for the file sandbox is done ONCE in
// worker.ts (only when sandboxRequested), so off-sandbox spawns keep the
Expand Down
3 changes: 3 additions & 0 deletions src/adapters/cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ export interface CliAdapter {
* `--model` flag (or equivalent) inject it here; adapters whose CLI has no
* such concept simply ignore the field. Empty / undefined → CLI default. */
model?: string;
/** Optional per-turn reasoning effort (codex `model_reasoning_effort`).
* Only codex/codex-app adapters honor it; others ignore. */
reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh';
/** When true, do not add adapter-default flags that bypass CLI approvals or disable sandboxing. */
disableCliBypass?: boolean;
/** Optional session-scoped skill plugin/root prepared by botmux. */
Expand Down
22 changes: 21 additions & 1 deletion src/codex-app-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ interface Args {
botName?: string;
botOpenId?: string;
locale?: string;
model?: string;
reasoningEffort?: string;
}

interface PendingRequest {
Expand All @@ -57,6 +59,8 @@ function parseArgs(argv: string[]): Args {
else if (key === '--bot-name' && val !== undefined) { out.botName = val; i++; }
else if (key === '--bot-open-id' && val !== undefined) { out.botOpenId = val; i++; }
else if (key === '--locale' && val !== undefined) { out.locale = val; i++; }
else if (key === '--model' && val !== undefined) { out.model = val; i++; }
else if (key === '--reasoning-effort' && val !== undefined) { out.reasoningEffort = val; i++; }
}
if (!out.sessionId) throw new Error('--session-id is required');
return out;
Expand Down Expand Up @@ -329,6 +333,12 @@ async function ensureThread(): Promise<string> {
cwd: args.cwd,
approvalPolicy: 'never',
sandbox: 'danger-full-access',
// Intentionally NO model / model_reasoning_effort here: on resume the
// app-server restores the thread's persisted {model, provider, effort}
// triple, and sending any single override would short-circuit that
// restoration (drifting model/provider to the current default). Per-turn
// overrides are applied on the fresh thread/start below only. Mirrors the
// RPC engine's resume contract (see codex-rpc-engine.resumeThread).
config: { shell_environment_policy: { inherit: 'all' } },
developerInstructions: appDeveloperInstructions(args),
excludeTurns: true,
Expand All @@ -352,7 +362,17 @@ async function ensureThread(): Promise<string> {
cwd: args.cwd,
approvalPolicy: 'never',
sandbox: 'danger-full-access',
config: { shell_environment_policy: { inherit: 'all' } },
config: {
shell_environment_policy: { inherit: 'all' },
// Per-turn reasoning effort → codex config key (ThreadStartParams accepts an
// arbitrary config map). Codex 0.145 accepts low/medium/high/xhigh and echoes
// xhigh back verbatim, so pass it through unchanged (no downgrade).
...(args.reasoningEffort ? { model_reasoning_effort: args.reasoningEffort } : {}),
},
// Per-turn model override → ThreadStartParams top-level model. Only set on a
// fresh thread/start, so a fold-in (existing thread) keeps its frozen model —
// matching the API's fresh-spawn-only override semantics.
...(args.model && args.model.trim() ? { model: args.model.trim() } : {}),
serviceName: 'botmux',
developerInstructions: appDeveloperInstructions(args),
ephemeral: false,
Expand Down
24 changes: 20 additions & 4 deletions src/codex-rpc-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,21 +169,37 @@ export class CodexRpcEngine {
* so RPC mode stays engaged across daemon restarts instead of reverting to
* the paste path. */
async resumeThread(threadId: string): Promise<string> {
const params: Json = { ...this.threadParams(), threadId, excludeTurns: true };
// forResume=true: a cold resume must NOT re-send ANY model-related override.
// The codex/TraeX app-server sees any single override (model OR
// model_reasoning_effort) as "caller is pinning config" and early-returns out
// of `merge_persisted_resume_metadata`, dropping the rest of the persisted
// {model, model_provider, reasoning_effort} triple back to the CURRENT
// process default. Re-sending only effort (per-turn override, new in PR #639)
// — or even the stable configured model (pre-existing on the shared engine) —
// therefore silently drifts model/provider whenever the app-server default
// changed between restarts. Verified on codex-cli 0.145.0 + traecli 0.200.19.
// The safe path is to send nothing model-related and let the app-server
// restore the full persisted triple. Fresh thread/start still stamps both.
const params: Json = { ...this.threadParams(true), threadId, excludeTurns: true };
delete params.serviceName; // resume keeps the original thread's identity
const r = await this.request('thread/resume', params);
this.threadId = String(r?.thread?.id ?? threadId);
return this.threadId;
}

private threadParams(): Json {
private threadParams(forResume = false): Json {
const config: Json = {
// Forward the full env (incl. BOTMUX_SESSION_ID / BOTMUX_LARK_APP_ID) to
// shell subprocesses so `botmux send` from within codex finds its bot.
shell_environment_policy: { inherit: 'all', ignore_default_excludes: true },
};
if (this.opts.model) config.model = this.opts.model;
if (this.opts.reasoningEffort) config.model_reasoning_effort = this.opts.reasoningEffort;
// Only stamp model/effort on a FRESH thread/start. On resume the app-server
// owns restoration of the persisted triple (see resumeThread) — sending
// either here would trip the app-server's model-resume-override short-circuit.
if (!forResume) {
if (this.opts.model) config.model = this.opts.model;
if (this.opts.reasoningEffort) config.model_reasoning_effort = this.opts.reasoningEffort;
}
return {
cwd: this.opts.cwd,
approvalPolicy: 'never',
Expand Down
15 changes: 15 additions & 0 deletions src/core/trigger-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,21 @@ export async function triggerSessionTurn(
session.lastMessageAt = new Date(now).toISOString();
session.workingDir = wd.workingDir;
session.cliId = bot.config.cliId;
// Per-turn model / reasoning-effort override — scoped to codex-family bots
// (the documented B-mode target) and to a freshly-created trigger session.
// Gating on cliId keeps the contract honest and bounded: it never silently
// changes the model of a Claude/Gemini/CoCo bot, and a fold-in to an existing
// worker never reaches here. reasoningEffort is codex-only regardless (other
// adapters ignore it); model is gated here so it can't leak to non-codex CLIs.
const isCodexFamily = bot.config.cliId === 'codex' || bot.config.cliId === 'codex-app';
if (isCodexFamily) {
if (typeof req.options?.model === 'string' && req.options.model.trim()) {
session.model = req.options.model.trim();
}
if (req.options?.reasoningEffort) {
session.reasoningEffort = req.options.reasoningEffort;
}
}
sessionStore.updateSession(session);

messageQueue.ensureQueue(anchor);
Expand Down
4 changes: 3 additions & 1 deletion src/core/worker-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ function sessionCliId(ds: DaemonSession, botCfg: { cliId: CliId }): CliId {
function sessionAgentConfig(
ds: DaemonSession,
botCfg: { cliId: CliId; cliPathOverride?: string; wrapperCli?: string; model?: string },
): { cliId: CliId; cliPathOverride?: string; wrapperCli?: string; model?: string } {
): { cliId: CliId; cliPathOverride?: string; wrapperCli?: string; model?: string; reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh' } {
// Freeze the agent launch config (cli / cliPath / wrapper / model) onto the
// session the first time a worker forks, so later bot-level edits never
// retroactively change a live session — same discipline as `sandbox`.
Expand All @@ -487,6 +487,7 @@ function sessionAgentConfig(
cliPathOverride: ds.session.cliPathOverride,
wrapperCli: ds.session.wrapperCli,
model: ds.session.model,
reasoningEffort: ds.session.reasoningEffort,
};
}

Expand Down Expand Up @@ -2381,6 +2382,7 @@ export function forkWorker(
wrapperCli: agentCfg.wrapperCli,
launchShell: botCfg.launchShell,
model: agentCfg.model,
reasoningEffort: agentCfg.reasoningEffort,
disableCliBypass: botCfg.disableCliBypass === true,
codexRpcInput: botCfg.codexRpcInput === true || config.codexRpcInputDefault,
// Startup commands run on every fresh spawn (incl. resume) so session-only
Expand Down
13 changes: 13 additions & 0 deletions src/services/trigger-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ export interface TriggerRequest {
* this loud trigger's turn. The streaming card / start notice still show;
* only the trailing transcript-driven summary is suppressed. */
suppressFinalOutput?: boolean;
/** Per-turn CLI model override (e.g. a codex model id). Applies only to a
* freshly-spawned session; ignored when folding into an existing worker.
* Empty/omitted → the bot's configured default. */
model?: string;
/** Per-turn reasoning effort (codex `model_reasoning_effort`). Same
* fresh-spawn-only semantics as `model`. */
reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh';
};
}

Expand Down Expand Up @@ -181,5 +188,11 @@ export function validateTriggerRequest(raw: unknown): { ok: true; request: Trigg
if (options.suppressFinalOutput !== undefined && typeof options.suppressFinalOutput !== 'boolean') {
return { ok: false, status: 400, body: { ok: false, errorCode: 'bad_request', error: 'options.suppressFinalOutput must be a boolean' } };
}
if (options.model !== undefined && (typeof options.model !== 'string' || options.model.length > 200)) {
return { ok: false, status: 400, body: { ok: false, errorCode: 'bad_request', error: 'options.model must be a string (<=200 chars)' } };
}
if (options.reasoningEffort !== undefined && !['low', 'medium', 'high', 'xhigh'].includes(options.reasoningEffort as string)) {
return { ok: false, status: 400, body: { ok: false, errorCode: 'bad_request', error: 'options.reasoningEffort must be one of low|medium|high|xhigh' } };
}
return { ok: true, request: raw as unknown as TriggerRequest };
}
5 changes: 4 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,9 @@ export interface Session {
wrapperCli?: string;
/** Optional model frozen at creation so historical sessions resume with their original model. */
model?: string;
/** Optional codex reasoning effort frozen at creation (per-turn API override).
* Only meaningful for codex/codex-app; injected as model_reasoning_effort at spawn. */
reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh';
/**
* True once `cliId`/`cliPathOverride`/`wrapperCli`/`model` have been frozen for
* this session (see `sessionAgentConfig`). Gates the one-time freeze so it runs
Expand Down Expand Up @@ -603,7 +606,7 @@ export interface CliTurnPayload {

/** Messages sent from Daemon to Worker */
export type DaemonToWorker =
| { type: 'init'; sessionId: string; chatId: string; chatType?: 'group' | 'p2p'; rootMessageId: string; workingDir: string; cliId: string; cliPathOverride?: string; wrapperCli?: string; launchShell?: string; model?: string; disableCliBypass?: boolean; codexRpcInput?: boolean; startupCommands?: string[]; env?: Record<string, string>; sandbox?: boolean; sandboxPaths?: { readWrite?: string[]; readOnly?: string[]; deny?: string[] }; sandboxHidePaths?: string[]; sandboxReadonlyPaths?: string[]; sandboxNetwork?: boolean; readIsolation?: boolean; readDenyExtraPaths?: string[]; daemonBootId?: string; backendType: BackendType; persistentBackendTarget?: PersistentBackendTarget; backendConfig?: RiffBackendConfig; riffParentTaskId?: string; riffRepoDirs?: string[]; deferredScheduleRun?: Session['deferredScheduleRun']; nativeSessionTitle?: string; nativeSessionTitlePrompt?: string; prompt: string; promptCodexAppInput?: CodexAppTurnInput; resume?: boolean; cliSessionId?: string; originalSessionId?: string; ownerOpenId?: string; webPort?: number; larkAppId: string; larkAppSecret: string; brand?: 'feishu' | 'lark'; botName?: string; botOpenId?: string; locale?: 'zh' | 'en'; turnId?: string; dispatchAttempt?: number; vcMeetingImTurnOrigin?: VcMeetingImTurnOrigin; pluginBindings?: string[]; skillPolicy?: BotSkillPolicy; skillPluginDir?: string; skillReadonlyRoots?: string[]; adoptMode?: boolean; adoptSource?: 'tmux' | 'herdr' | 'zellij'; adoptTmuxTarget?: string; adoptZellijSession?: string; adoptZellijPaneId?: string; adoptHerdrSessionName?: string; adoptHerdrTarget?: string; adoptHerdrPaneId?: string; adoptPaneCols?: number; adoptPaneRows?: number; bridgeJsonlPath?: string; adoptCliPid?: number; adoptCwd?: string; adoptRestoredFromMetadata?: boolean; runnerBuildId?: string; persistedRunnerBuildId?: string; restartAttemptId?: string }
| { type: 'init'; sessionId: string; chatId: string; chatType?: 'group' | 'p2p'; rootMessageId: string; workingDir: string; cliId: string; cliPathOverride?: string; wrapperCli?: string; launchShell?: string; model?: string; reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh'; disableCliBypass?: boolean; codexRpcInput?: boolean; startupCommands?: string[]; env?: Record<string, string>; sandbox?: boolean; sandboxPaths?: { readWrite?: string[]; readOnly?: string[]; deny?: string[] }; sandboxHidePaths?: string[]; sandboxReadonlyPaths?: string[]; sandboxNetwork?: boolean; readIsolation?: boolean; readDenyExtraPaths?: string[]; daemonBootId?: string; backendType: BackendType; persistentBackendTarget?: PersistentBackendTarget; backendConfig?: RiffBackendConfig; riffParentTaskId?: string; riffRepoDirs?: string[]; deferredScheduleRun?: Session['deferredScheduleRun']; nativeSessionTitle?: string; nativeSessionTitlePrompt?: string; prompt: string; promptCodexAppInput?: CodexAppTurnInput; resume?: boolean; cliSessionId?: string; originalSessionId?: string; ownerOpenId?: string; webPort?: number; larkAppId: string; larkAppSecret: string; brand?: 'feishu' | 'lark'; botName?: string; botOpenId?: string; locale?: 'zh' | 'en'; turnId?: string; dispatchAttempt?: number; vcMeetingImTurnOrigin?: VcMeetingImTurnOrigin; pluginBindings?: string[]; skillPolicy?: BotSkillPolicy; skillPluginDir?: string; skillReadonlyRoots?: string[]; adoptMode?: boolean; adoptSource?: 'tmux' | 'herdr' | 'zellij'; adoptTmuxTarget?: string; adoptZellijSession?: string; adoptZellijPaneId?: string; adoptHerdrSessionName?: string; adoptHerdrTarget?: string; adoptHerdrPaneId?: string; adoptPaneCols?: number; adoptPaneRows?: number; bridgeJsonlPath?: string; adoptCliPid?: number; adoptCwd?: string; adoptRestoredFromMetadata?: boolean; runnerBuildId?: string; persistedRunnerBuildId?: string; restartAttemptId?: string }
| { type: 'message'; content: string; codexAppInput?: CodexAppTurnInput; nativeSessionTitle?: string; nativeSessionTitlePrompt?: string; turnId?: string; dispatchAttempt?: number; vcMeetingImTurnOrigin?: VcMeetingImTurnOrigin }
/** Literal slash-command passthrough. `followUpContent` rides along so the
* worker enqueues it strictly AFTER the slash command's Enter — two separate
Expand Down
Loading
Loading