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
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,58 @@ headless --check

When no agent is specified, Headless selects the first installed agent in this order: `codex`, `claude`, `pi`, `opencode`, `gemini`, `antigravity`, `cursor`. ACP-compatible agents are explicit-only: use `headless acp --acp-agent ...` or `headless acp --acp-command ...`.

## Billing and subscription fallback

Noninteractive Claude and Codex invocations default to `--billing auto`: use an
available subscription, then switch once to paid authentication after a native
subscription limit or a subscription-specific Codex model rejection. GPT-5.4
and `gpt-5.4-2026-03-05` use OpenAI API billing directly. Other harnesses keep
their native authentication.

```bash
headless codex --prompt "Run the experiment" # subscription first
headless codex --model gpt-5.4 --prompt "Run the experiment" # OpenAI API
headless claude --billing subscription --prompt "Review results" # never switch to paid
headless claude --billing api --prompt "Continue the experiment" # Amazon Bedrock
```

Codex API billing needs `CODEX_API_KEY` or `OPENAI_API_KEY`. Claude's paid route
uses Amazon Bedrock, with an AWS region and credentials available to the native
CLI; it retains the requested model and native Bedrock model mapping. An
Anthropic API key alone does not configure this backup. For Docker/Modal, supply
credentials accessible inside the container (for example AWS access key, secret,
session token, and region); a host AWS profile or credential-file path alone is
not portable.

Policy precedence: `--billing` > `HEADLESS_BILLING` > `billing` in
`[agents.claude]`/`[agents.codex]` > `auto`. Choose `subscription` to disallow paid
fallback. Explicit custom Codex profiles/providers keep native auth under `auto`
and cannot be combined with explicit subscription/API routing. With no detected
subscription or configured backup, `auto` preserves native authentication.

Fallback preserves the workspace, native session, permissions, reasoning effort,
and original deadline. Completed work resumes with a continuation prompt; the
original task is not replayed after partial execution. If safe resumption or
backup credentials are unavailable, Headless exits with status 78. Generic
errors, tool output, interruptions, and timeouts do not trigger fallback. Paid
provider limits still apply; Headless does not impose a local dollar cap.

The policy applies locally, in Docker, and in Modal. Docker keeps an anonymous
private home across both attempts, removes it after native success, and reports
its retained path on failure for recovery. `--session` homes remain durable.
On Windows, anonymous Docker runs share a Docker-managed volume across attempts;
success removes it and failure reports its name for recovery. Named durable
Docker sessions remain unsupported on Windows.
Interactive/tmux invocations use native authentication; explicit `--billing`
with `--tmux` is rejected.

`--usage` includes `billing.attempts` with route, transition reason, and each
attempt's usage/cost provenance. The top-level token counts aggregate attempts;
mixed or missing cost bases leave aggregate cost unavailable instead of mixing
estimates with reported charges. Subscription cost estimates are API list-price
comparisons, not subscription charges. Auth changes affect child environments
only; Headless never replaces shared login files.

## Native TUI Completion

Use `--tmux --wait --delete` when you want Headless to launch the agent in its native TUI, wait for the final native transcript message, print that message, and then terminate the tmux session after the prompt completes.
Expand Down
2 changes: 2 additions & 0 deletions config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ list_waiting_after_ms = 15000

[agents.claude]
model = "claude-opus-4-6"
# billing = "auto" # Subscription first; Amazon Bedrock after a subscription limit.
# reasoning_effort = "xhigh"

[agents.codex]
model = "gpt-5.5"
# billing = "auto" # Subscription first; OpenAI API after a subscription limit.
# reasoning_effort = "xhigh"

[agents.cursor]
Expand Down
154 changes: 154 additions & 0 deletions src/billing-events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { StringDecoder } from "node:string_decoder";
import type { AgentName } from "./types.js";

export type BillingFailureReason = "subscription-quota" | "subscription-model-unsupported";
export const MAX_BILLING_EVENT_BYTES = 1024 * 1024;
type RecordValue = Record<string, unknown>;

function object(value: unknown): RecordValue {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as RecordValue : {};
}

function sessionId(value: unknown): string | undefined {
return typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value) ? value : undefined;
}

// Display strings and reset-time format from the installed Codex 0.153.4 binary.
const codexQuotaPrefixes = [
"You've hit your usage limit.",
"You've hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus),",
"You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits",
"You've hit your usage limit. To get more access now, send a request to your admin",
"You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits",
];
const codexResetSuffix = /^(?: Try again| or try again) (?:later|at (?:(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [1-3]?\d, \d{4} )?(?:[1-9]|1[0-2]):[0-5]\d [AP]M)\.$/;

function codexQuotaMessage(value: string): boolean {
if (value.length > 512) return false;
return codexQuotaPrefixes.some((prefix) => value.startsWith(prefix) &&
(value === prefix || codexResetSuffix.test(value.slice(prefix.length))));
}

function codexFailure(value: unknown): BillingFailureReason | undefined {
let error = object(value);
if (typeof value === "string") {
if (codexQuotaMessage(value)) return "subscription-quota";
try { error = object(JSON.parse(value)); } catch { return undefined; }
}
const detail = object(error.error);
if (error.type === "usage_limit_reached" || detail.type === "usage_limit_reached" ||
error.code === "usage_limit_reached" || detail.code === "usage_limit_reached") {
return "subscription-quota";
}
if (error.status === 400 && detail.type === "invalid_request_error" &&
typeof detail.message === "string" &&
/^The '[A-Za-z0-9._-]+' model is not supported when using Codex with a ChatGPT account\.$/.test(detail.message)) {
return "subscription-model-unsupported";
}
return undefined;
}

/** Observes native envelope fields only; tool output and assistant prose are never errors. */
export class BillingEventCollector {
failureReason: BillingFailureReason | undefined;
nativeSessionId: string | undefined;
hasWork = false;
failed = false;
private pending = "";
private pendingBytes = 0;
private skipping = false;
private readonly decoder = new StringDecoder("utf8");

constructor(private readonly agent: AgentName) {}

write(chunk: string | Buffer): void {
const text = typeof chunk === "string" ? chunk : this.decoder.write(chunk);
let start = 0;
while (start < text.length) {
const newline = text.indexOf("\n", start);
const end = newline < 0 ? text.length : newline;
const segment = text.slice(start, end);
if (!this.skipping) {
this.pendingBytes += Buffer.byteLength(segment);
if (this.pendingBytes > MAX_BILLING_EVENT_BYTES) {
this.pending = "";
this.skipping = true;
// Lost work evidence must prevent a blind replay without a native session.
this.hasWork = true;
} else {
this.pending += segment;
}
}
if (newline < 0) break;
if (!this.skipping) this.consume(this.pending);
this.pending = "";
this.pendingBytes = 0;
this.skipping = false;
start = newline + 1;
}
}

end(): void {
this.write(this.decoder.end());
if (!this.skipping && this.pending) this.consume(this.pending);
this.pending = "";
this.pendingBytes = 0;
}

private consume(line: string): void {
let event: RecordValue;
try { event = object(JSON.parse(line)); } catch { return; }
if (this.agent === "codex") this.consumeCodex(event);
if (this.agent === "claude") this.consumeClaude(event);
}

private consumeCodex(event: RecordValue): void {
if (event.type === "turn.completed") {
this.failed = false;
this.failureReason = undefined;
}
if (event.type === "thread.started") {
this.nativeSessionId ??= sessionId(event.thread_id);
}
if (event.type === "item.started" || event.type === "item.completed" || event.type === "item.updated") {
const item = object(event.item);
if (typeof item.type === "string" && item.type !== "error") this.hasWork = true;
}
if (event.type === "error" || event.type === "turn.failed") {
this.failureReason ??= codexFailure(event.error) ?? codexFailure(event.message) ??
codexFailure(object(event.error).message) ?? codexFailure(event);
// Codex also emits recoverable error notices; the native terminal is authoritative.
if (event.type === "turn.failed" || this.failureReason) this.failed = true;
}
}

private consumeClaude(event: RecordValue): void {
if (event.type === "system" && event.subtype === "init") {
this.nativeSessionId ??= sessionId(event.session_id);
}
if (event.type === "rate_limit_event") {
const limit = object(event.rate_limit_info);
if (limit.status === "rejected" &&
["five_hour", "seven_day", "seven_day_opus", "seven_day_sonnet"].includes(String(limit.rateLimitType))) {
this.failureReason = "subscription-quota";
this.failed = true;
}
}
if (event.type === "assistant") {
const message = object(event.message);
if (message.model !== "<synthetic>" && Array.isArray(message.content) && message.content.length) {
this.hasWork = true;
}
}
if (event.type === "result") {
this.nativeSessionId ??= sessionId(event.session_id);
if (event.is_error === true || event.terminal_reason === "api_error" ||
(typeof event.subtype === "string" && event.subtype.startsWith("error_"))) this.failed = true;
else if (event.is_error === false && event.subtype === "success") {
this.failed = false;
this.failureReason = undefined;
}
}
}
}
106 changes: 106 additions & 0 deletions src/billing-run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { prepareBillingAttempt, type BillingRoute } from "./billing.js";
import { BillingEventCollector, type BillingFailureReason } from "./billing-events.js";
import { aggregateBillingUsage, type BillingUsageReport } from "./billing-usage.js";
import type { AgentName, BillingMode, BuildOptions, Env } from "./types.js";
import type { UsageSummary } from "./usage.js";

export interface BillingExecutionResult {
code: number;
stdout: string;
usageTrace?: string;
finalMessageTrace?: string;
stdoutReceived?: boolean;
stdoutEndsWithNewline?: boolean;
}

export interface BillingExecutionAttempt {
route: BillingRoute;
env: Env;
options: BuildOptions;
timeoutSeconds?: number;
observe: (chunk: string) => void;
}

export interface BillingRunOptions {
agent: AgentName;
mode: BillingMode;
env: Env;
options: BuildOptions;
timeoutSeconds?: number;
execute: (attempt: BillingExecutionAttempt) => Promise<BillingExecutionResult>;
reportUsage?: (trace: string, route: BillingRoute) => Promise<UsageSummary>;
onTransition?: (event: { type: "billing_transition"; from: BillingRoute; to: BillingRoute; reason: BillingFailureReason }) => void;
now?: () => number;
}

export interface BillingRunResult {
result: BillingExecutionResult;
usage?: BillingUsageReport;
nativeSessionId?: string;
error?: string;
}

const terminated = new Set([124, 130, 137, 143]);
const continuation = "Continue from where you left off. Your previous turn was interrupted by a subscription usage limit. Preserve completed work and do not repeat completed commands.";

/** One billing transition, sharing the invocation's deadline and native transcript. */
export async function runWithBilling(input: BillingRunOptions): Promise<BillingRunResult> {
const now = input.now ?? Date.now;
const deadline = input.timeoutSeconds === undefined ? undefined : now() + input.timeoutSeconds * 1000;
let options = input.options;
let attempt = prepareBillingAttempt(input.agent, options, input.env, input.mode);
const reports: Parameters<typeof aggregateBillingUsage>[0] = [];
let reason: BillingFailureReason | undefined;
let nativeSessionId: string | undefined;
let error: string | undefined;
let result: BillingExecutionResult = { code: 124, stdout: "" };

for (let index = 0; index < 2; index++) {
const remaining = deadline === undefined ? undefined : (deadline - now()) / 1000;
if (remaining !== undefined && remaining <= 0) {
result = { ...result, code: 124 };
break;
}
const events = new BillingEventCollector(input.agent);
result = await input.execute({ ...attempt, options, timeoutSeconds: remaining, observe: (chunk) => events.write(chunk) });
events.end();
nativeSessionId = events.nativeSessionId ?? nativeSessionId;
if (input.reportUsage) {
const trace = result.usageTrace || result.stdout || result.finalMessageTrace || "";
reports.push({ route: attempt.route, reason, usage: await input.reportUsage(trace, attempt.route) });
}
if (terminated.has(result.code)) break;
if (events.failed && result.code === 0) result = { ...result, code: 1 };
if (!events.failureReason) break;
// Explicit subscription-only policy and exhausted paid routes are terminal.
if (input.mode !== "auto" || attempt.route !== "subscription" || index !== 0) {
result = { ...result, code: 78 };
error = `billing unavailable: ${events.failureReason}; no further billing fallback`;
break;
}
if (deadline !== undefined && now() >= deadline) {
result = { ...result, code: 124 };
break;
}
const resumeId = nativeSessionId ?? options.sessionId;
if (events.hasWork && !resumeId) {
result = { ...result, code: 78 };
error = "billing fallback cannot safely resume partial work: native session ID unavailable";
break;
}
try {
const next = prepareBillingAttempt(input.agent, options, input.env, "api");
reason = events.failureReason;
input.onTransition?.({ type: "billing_transition", from: attempt.route, to: next.route, reason });
attempt = next;
if (resumeId) {
options = { ...options, prompt: continuation, promptFile: undefined, sessionMode: "resume", sessionId: resumeId };
}
} catch (failure) {
result = { ...result, code: 78 };
error = failure instanceof Error ? failure.message : "billing fallback unavailable";
break;
}
}
return { result, nativeSessionId, error, ...(reports.length ? { usage: aggregateBillingUsage(reports) } : {}) };
}
50 changes: 50 additions & 0 deletions src/billing-usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { BillingFailureReason } from "./billing-events.js";
import type { UsageCostBreakdown, UsageSummary } from "./usage.js";

export type BillingRoute = "subscription" | "openai-api" | "bedrock" | "native";
export interface BillingAttempt {
route: BillingRoute;
reason?: BillingFailureReason;
usage: UsageSummary;
}
export interface BillingUsageReport extends UsageSummary {
billing: { attempts: BillingAttempt[] };
}

/** Keep incompatible cost valuations separate rather than suggesting an actual API bill. */
export function aggregateBillingUsage(attempts: BillingAttempt[]): BillingUsageReport {
if (attempts.length < 1 || attempts.length > 2) {
throw new Error("Billing usage requires one or two attempts");
}
const summaries = attempts.map((attempt) => attempt.usage);
const last = summaries[summaries.length - 1];
const complete = summaries.every((summary) => summary.usageStatus === "reported");
const comparable = complete && summaries.every((summary) =>
summary.cost !== null && summary.costBasis === last.costBasis &&
summary.pricingSource === last.pricingSource && summary.pricingStatus === last.pricingStatus);
const sum = (key: "inputTokens" | "cacheReadTokens" | "cacheWriteTokens" | "outputTokens" |
"reasoningOutputTokens" | "totalTokens") => summaries.reduce((total, summary) => total + summary[key], 0);
let cost: UsageCostBreakdown | null = null;
if (comparable) {
const component = (key: keyof UsageCostBreakdown): number | null => {
const values = summaries.map((summary) => summary.cost![key]);
return values.some((value) => value === null) ? null :
values.reduce<number>((total, value) => total + value!, 0);
};
cost = { input: component("input"), cacheRead: component("cacheRead"),
cacheWrite: component("cacheWrite"), output: component("output"), total: component("total") };
}
const { modelBreakdowns: _parts, ...publicLast } = last;
return {
...publicLast,
inputTokens: sum("inputTokens"), cacheReadTokens: sum("cacheReadTokens"),
cacheWriteTokens: sum("cacheWriteTokens"), outputTokens: sum("outputTokens"),
reasoningOutputTokens: sum("reasoningOutputTokens"), totalTokens: sum("totalTokens"),
usageStatus: complete ? "reported" : "missing",
cost,
costBasis: comparable ? last.costBasis : null,
pricingSource: comparable ? last.pricingSource : null,
pricingStatus: comparable ? last.pricingStatus : "missing",
billing: { attempts: attempts.map((attempt) => ({ ...attempt, usage: { ...attempt.usage } })) },
};
}
Loading
Loading