Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- Added Codex profile selection with `--profile`, including local, cron, tmux, named-session, Docker, and Modal execution while preserving explicit Headless model overrides.
- Added remote Codex profile and model-catalog seeding plus automatic `SAKANA_API_KEY` forwarding for Fugu runs in Docker and Modal.
- Hardened durable Docker profile refreshes with private host-owned profile directories, bounded regular-file reads, symlink-safe atomic replacement, and stale provider-config cleanup.
- Added normalized `--reasoning-effort max` support, including Codex's native `model_reasoning_effort="max"` setting and Cursor's parameterized `[effort=max]` model override.
- Added an explicit Fast-mode opt-in for Codex and Claude: `--fast` in the CLI and `fast=False` sync/async Python SDK arguments. Standard mode is explicitly enforced by default, including scheduled, Docker, Modal, and newly launched tmux runs; existing tmux sessions reject Fast changes they cannot apply.
- Fixed Antigravity one-shot runs to forward Headless's effective local, Docker, or Modal command timeout to `agy --print-timeout`, so long reasoning runs are not cut off by Agy's independent five-minute default.
- Updated the bundled Docker image to Antigravity CLI 1.1.8, using the official release assets and published SHA-256 digests for Linux AMD64 and ARM64.
Expand Down
2 changes: 1 addition & 1 deletion config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ model = "claude-opus-4-6"

[agents.codex]
model = "gpt-5.5"
# reasoning_effort = "xhigh"
# reasoning_effort = "max" # Use only with a model/backend that supports max.

[agents.cursor]
model = "gpt-5.5"
Expand Down
6 changes: 4 additions & 2 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ Environment equivalents are also supported: `HEADLESS_ACP_AGENT`, `HEADLESS_ACP_

By default, Headless uses each agent's native auto-approve/bypass mode. Pass `--allow read-only` to use each agent's read-only or planning mode where available. Pass `--allow yolo` to request full tool access explicitly.

Pass `--reasoning-effort low|medium|high|xhigh` or `--effort low|medium|high|xhigh` to request a normalized reasoning effort for agents with native support. Claude receives `--effort`, Codex receives `model_reasoning_effort`, Cursor combines the model family and effort into Cursor's model variant string, OpenCode receives `--variant` in one-shot mode, and Pi receives `--thinking`. Docker and Modal inherit the same one-shot command. In tmux mode, Claude, Codex, Cursor, and Pi receive their interactive effort flags. Antigravity, Gemini, and OpenCode tmux currently accept the option, leave the command unchanged, and print a warning.
Pass `--reasoning-effort low|medium|high|xhigh|max` or `--effort low|medium|high|xhigh|max` to request a normalized reasoning effort for agents with native support. Claude receives `--effort`, Codex receives `model_reasoning_effort`, Cursor combines legacy model families and effort into Cursor model variants and uses its parameterized `[effort=max]` override for `max`, OpenCode receives `--variant` in one-shot mode, and Pi receives `--thinking`. Docker and Modal inherit the same one-shot command. In tmux mode, Claude, Codex, Cursor, and Pi receive their interactive effort flags. Antigravity, Gemini, and OpenCode tmux currently accept the option, leave the command unchanged, and print a warning.

Effort availability remains model- and backend-specific. Headless forwards the selected value and does not silently downgrade it when the native backend rejects an unsupported effort.

Fast mode is off by default and is controlled per invocation, not through `~/.headless/config.toml`. Pass `--fast` to opt into the provider's native Fast mode for Codex or Claude; other agents reject the flag. Headless explicitly sends Codex `service_tier="default"` or `service_tier="fast"`, and Claude `fastMode: false` or `true`, so an inherited provider config cannot silently enable Fast mode. The same option works for Docker, Modal, tmux, and `cron add` runs.

Expand Down Expand Up @@ -291,7 +293,7 @@ Options:
- `--model`, `--agent-model`: model override passed to the agent CLI.
- `--profile`: Codex configuration profile for this invocation; persisted by named sessions and coordinated run nodes.
- `--fast`: opt into Fast mode for Codex or Claude; off by default and not config-driven.
- `--reasoning-effort`, `--effort`: normalized reasoning effort, one of `low`, `medium`, `high`, or `xhigh`.
- `--reasoning-effort`, `--effort`: normalized reasoning effort, one of `low`, `medium`, `high`, `xhigh`, or `max`.
- `--allow`: permission mode, either `read-only` or `yolo`.
- `--acp-agent`: with `acp`, resolve an ACP server from the registry by id or name.
- `--acp-command`: with `acp`, run a custom ACP server command such as `atlas alta agent run`.
Expand Down
2 changes: 1 addition & 1 deletion python/src/headless_cli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"acp",
]
AllowMode = Literal["read-only", "yolo"]
ReasoningEffort = Literal["low", "medium", "high", "xhigh"]
ReasoningEffort = Literal["low", "medium", "high", "xhigh", "max"]
Role = Literal["orchestrator", "explorer", "worker", "reviewer"]
Coordination = Literal["session", "tmux", "oneshot"]
RunStatus = Literal[
Expand Down
34 changes: 31 additions & 3 deletions src/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,33 @@ function withCursorAllow(args: string[], allow: AllowMode | undefined): string[]
return allow === "yolo" || allow === undefined ? [...args, "--force"] : args;
}

function cursorBaseModel(model: string): string {
return model.replace(/-(low|medium|high|xhigh|extra-high|max)(-fast)?$/i, "");
}

function isCursorReasoningVariant(model: string): boolean {
return /-(low|medium|high|xhigh|extra-high)(-fast)?$/i.test(model);
return cursorBaseModel(model) !== model;
}

function withCursorReasoningEffort(model: string, effort: ReasoningEffort): string {
const openBracket = model.lastIndexOf("[");
if (openBracket === -1 || !model.endsWith("]")) {
return `${model}[effort=${effort}]`;
}

const baseModel = model.slice(0, openBracket);
const parameters = model
.slice(openBracket + 1, -1)
.split(",")
.map((parameter) => parameter.trim())
.filter(Boolean);
const effortIndex = parameters.findIndex((parameter) => /^effort\s*=/i.test(parameter));
if (effortIndex >= 0) {
parameters[effortIndex] = `effort=${effort}`;
} else {
parameters.push(`effort=${effort}`);
}
return `${baseModel}[${parameters.join(",")}]`;
}

function supportsCursorReasoningVariants(model: string): boolean {
Expand Down Expand Up @@ -120,10 +145,13 @@ function cursorReasoningVariant(model: string, effort: ReasoningEffort): string

export function cursorModel(options: Pick<BuildOptions, "model" | "reasoningEffort">): string {
const model = options.model ?? DEFAULT_CURSOR_MODEL;
if (isCursorReasoningVariant(model)) return model;
if (!supportsCursorReasoningVariants(model)) return model;
const effort = options.reasoningEffort ?? (options.model ? undefined : "medium");
if (!effort) return model;
if (effort === "max") {
return withCursorReasoningEffort(isCursorReasoningVariant(model) ? cursorBaseModel(model) : model, effort);
}
if (isCursorReasoningVariant(model)) return model;
if (!supportsCursorReasoningVariants(model)) return model;
return cursorReasoningVariant(model, effort) ?? model;
}

Expand Down
4 changes: 2 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ function usage(): string {
" --profile <name> Codex configuration profile.",
" --fast Enable Fast mode for Codex or Claude.",
" --no-fast Disable ambient Fast mode for Codex or Claude.",
" --reasoning-effort, --effort <level> Reasoning effort: low, medium, high, or xhigh.",
" --reasoning-effort, --effort <level> Reasoning effort: low, medium, high, xhigh, or max.",
" --allow <mode> Permission mode: read-only or yolo.",
" --acp-agent <id> With acp, resolve an ACP server from the registry by id or name.",
" --acp-command <cmd> With acp, run a custom ACP server command, e.g. 'atlas alta agent run'.",
Expand Down Expand Up @@ -733,7 +733,7 @@ function validateSafeName(value: string | undefined, label: string): string {
}

function parseReasoningEffort(value: string): ReasoningEffort {
if (value === "low" || value === "medium" || value === "high" || value === "xhigh") {
if (value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max") {
return value;
}
throw new CliError(`unsupported reasoning effort: ${value}`);
Expand Down
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ function parseRoleName(value: string, lineNumber: number): Role {
}

function parseConfigReasoningEffort(value: string, lineNumber: number): ReasoningEffort {
if (value === "low" || value === "medium" || value === "high" || value === "xhigh") {
if (value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max") {
return value;
}
throw new Error(`unsupported headless config reasoning_effort at line ${lineNumber}: ${value}`);
Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export type PromptFileMode = "argument" | "stdin";

export type AllowMode = "read-only" | "yolo";

export type ReasoningEffort = "low" | "medium" | "high" | "xhigh";
export type ReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max";

export type Env = Record<string, string | undefined>;

Expand Down
6 changes: 5 additions & 1 deletion src/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,10 +569,14 @@ function findPricingModel(

function pricingModelCandidates(model: string): string[] {
const candidates = [model];
const cursorVariant = model.match(/^(.+?)-(extra-high|xhigh|medium|high|low)(-fast)?$/i);
const cursorVariant = model.match(/^(.+?)-(extra-high|xhigh|medium|high|low|max)(-fast)?$/i);
if (cursorVariant?.[1]) {
candidates.push(cursorVariant[1]);
}
const cursorParameterized = model.match(/^(.+?)\[[^\]]*\]$/);
if (cursorParameterized?.[1]) {
candidates.push(cursorParameterized[1]);
}
return candidates;
}

Expand Down
34 changes: 32 additions & 2 deletions tests/allow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,12 +240,12 @@ test("CLI rejects invalid allow mode", async () => {

test("CLI rejects invalid reasoning effort", async () => {
const stderr: string[] = [];
const code = await runCli(["codex", "--reasoning-effort", "max", "--prompt", "hello"], {
const code = await runCli(["codex", "--reasoning-effort", "unsupported", "--prompt", "hello"], {
stderr: (text) => stderr.push(text),
});

assert.equal(code, 2);
assert.match(stderr.join(""), /unsupported reasoning effort: max/);
assert.match(stderr.join(""), /unsupported reasoning effort: unsupported/);
});

test("preserves ambient fast settings unless a mode is explicit", () => {
Expand Down Expand Up @@ -366,6 +366,17 @@ test("CLI print-command includes reasoning effort flags", async () => {
assert.match(stdout.join(""), /-c 'model_reasoning_effort="high"'/);
});

test("CLI accepts max reasoning effort", async () => {
const stdout: string[] = [];
const code = await runCli(
["codex", "--model", "gpt-5.6", "--reasoning-effort", "max", "--prompt", "hello", "--print-command"],
{ stdout: (text) => stdout.push(text) },
);

assert.equal(code, 0);
assert.match(stdout.join(""), /--model gpt-5\.6 .*model_reasoning_effort="max"/);
});

test("CLI accepts --effort as an alias for --reasoning-effort", async () => {
const stdout: string[] = [];
const code = await runCli(["codex", "--effort", "high", "--prompt", "hello", "--print-command"], {
Expand All @@ -389,6 +400,25 @@ test("CLI maps Cursor reasoning effort to model variants", async () => {
assert.equal(stderr.join(""), "");
});

test("CLI maps Cursor max reasoning effort to a parameterized model", async () => {
const stdout: string[] = [];
const stderr: string[] = [];
const code = await runCli(
["cursor", "--model", "gpt-5.6", "--reasoning-effort", "max", "--prompt", "hello", "--print-command"],
{
stdout: (text) => stdout.push(text),
stderr: (text) => stderr.push(text),
},
);

assert.equal(code, 0);
assert.equal(
stdout.join(""),
"agent -p --trust --force --output-format stream-json --model 'gpt-5.6[effort=max]' hello\n",
);
assert.equal(stderr.join(""), "");
});

test("CLI warns when Gemini reasoning effort is unsupported", async () => {
const stdout: string[] = [];
const stderr: string[] = [];
Expand Down
82 changes: 81 additions & 1 deletion tests/headless.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,30 @@ test("builds reasoning effort flags for supported agents", () => {
"-",
]);

assert.deepEqual(buildAgentCommand("codex", { prompt: "hello", reasoningEffort: "max" }, {}).args, [
"--dangerously-bypass-approvals-and-sandbox",
"exec",
"--model",
"gpt-5.5",
"-c",
'model_reasoning_effort="max"',
"--json",
"--skip-git-repo-check",
"-",
]);

const nativeMaxEffortFlags: Array<[AgentName, string]> = [
["claude", "--effort"],
["opencode", "--variant"],
["pi", "--thinking"],
];
for (const [agent, flag] of nativeMaxEffortFlags) {
const args = buildAgentCommand(agent, { prompt: "hello", reasoningEffort: "max" }, {}).args;
const flagIndex = args.indexOf(flag);
assert.notEqual(flagIndex, -1, `${agent} should receive ${flag}`);
assert.equal(args[flagIndex + 1], "max", `${agent} should receive max effort`);
}

assert.deepEqual(buildAgentCommand("claude", { prompt: "hello", reasoningEffort: "xhigh" }, {}).args, [
"--model",
"claude-opus-4-6",
Expand Down Expand Up @@ -349,6 +373,49 @@ test("maps Cursor reasoning effort to model variants and leaves Gemini unchanged
args: ["-p", "--trust", "--force", "--output-format", "stream-json", "--model", "gpt-5.5-extra-high", "hello"],
});

assert.deepEqual(buildAgentCommand("cursor", { prompt: "hello", model: "gpt-5.6", reasoningEffort: "max" }, {}), {
command: "agent",
args: ["-p", "--trust", "--force", "--output-format", "stream-json", "--model", "gpt-5.6[effort=max]", "hello"],
});

assert.deepEqual(
buildAgentCommand("cursor", { prompt: "hello", model: "claude-opus-4-6", reasoningEffort: "max" }, {}),
{
command: "agent",
args: [
"-p",
"--trust",
"--force",
"--output-format",
"stream-json",
"--model",
"claude-opus-4-6[effort=max]",
"hello",
],
},
);

assert.deepEqual(
buildAgentCommand(
"cursor",
{ prompt: "hello", model: "gpt-5.6[context=1m,effort=high]", reasoningEffort: "max" },
{},
),
{
command: "agent",
args: [
"-p",
"--trust",
"--force",
"--output-format",
"stream-json",
"--model",
"gpt-5.6[context=1m,effort=max]",
"hello",
],
},
);

assert.deepEqual(buildAgentCommand("cursor", { prompt: "hello", model: "gpt-5.5", reasoningEffort: "xhigh" }, {}), {
command: "agent",
args: ["-p", "--trust", "--force", "--output-format", "stream-json", "--model", "gpt-5.5-extra-high", "hello"],
Expand Down Expand Up @@ -888,6 +955,15 @@ test("builds reasoning effort flags for supported interactive commands", () => {
],
});

assert.deepEqual(buildInteractiveAgentCommand("codex", { prompt: "hello", reasoningEffort: "max" }, {}).args, [
"--dangerously-bypass-approvals-and-sandbox",
"--model",
"gpt-5.5",
"-c",
'model_reasoning_effort="max"',
"hello",
]);

assert.deepEqual(buildInteractiveAgentCommand("claude", { prompt: "hello", reasoningEffort: "xhigh" }, {}), {
command: "claude",
args: ["--model", "claude-opus-4-6", "--effort", "xhigh", "--dangerously-skip-permissions", "hello"],
Expand Down Expand Up @@ -1219,8 +1295,12 @@ test("config parser accepts role sections and validates role fields", () => {
assert.throws(() => parseHeadlessConfig("[roles.scout]\nallow = \"read-only\"\n"), /unsupported headless config role/);
assert.throws(() => parseHeadlessConfig("[roles.explorer]\nunknown = \"value\"\n"), /unsupported headless role config key/);
assert.throws(() => parseHeadlessConfig("[roles.explorer]\nallow = \"maybe\"\n"), /unsupported headless config allow/);
assert.equal(
parseHeadlessConfig("[roles.explorer]\nreasoning_effort = \"max\"\n").roles.explorer?.reasoningEffort,
"max",
);
assert.throws(
() => parseHeadlessConfig("[roles.explorer]\nreasoning_effort = \"max\"\n"),
() => parseHeadlessConfig("[roles.explorer]\nreasoning_effort = \"unsupported\"\n"),
/unsupported headless config reasoning_effort/,
);
});
Expand Down
40 changes: 40 additions & 0 deletions tests/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,46 @@ test("prices Cursor effort model variants with base model rates", () => {
assert.equal(summary.pricingStatus, "priced");
});

test("prices Cursor parameterized effort models with base model rates", () => {
const trace = JSON.stringify({
type: "result",
usage: {
inputTokens: 1000,
outputTokens: 20,
cacheReadTokens: 400,
cacheWriteTokens: 0,
},
});

const summary = priceUsageSummary(
extractUsageSummary("cursor", trace, { model: "gpt-5.6[effort=max]" }),
{
openai: {
models: {
"gpt-5.6": {
cost: {
input: 4,
cache_read: 0.4,
output: 20,
},
},
},
},
},
);

assert.equal(summary.model, "gpt-5.6[effort=max]");
assert.deepEqual(summary.cost, {
input: 0.004,
cacheRead: 0.00016,
cacheWrite: 0,
output: 0.0004,
total: 0.00456,
});
assert.equal(summary.costBasis, "api-list-price-estimate");
assert.equal(summary.pricingStatus, "priced");
});

test("extracts Gemini multi-model usage and sums priced costs", () => {
const trace = JSON.stringify({
type: "result",
Expand Down
Loading