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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [6.9.6] - 2026-10-06

### Added

- **`--baseUrl` and `--apiKey` flags for headless mode.** Both are optional and route the request through an OpenAI-compatible endpoint instead of the MatterAI gateway. `--baseUrl` alone is enough when the endpoint doesn't require auth. When `--baseUrl` is given without `--model` (or `MATTERAI_MODEL`), the model defaults to `gpt-4o`. These flags set `MATTERAI_LLM_BASE_URL` / `MATTERAI_LLM_API_KEY` — deliberately distinct from the existing `MATTERAI_BASE_URL` / `MATTERAI_API_KEY`, which override the gateway URL and auth token and would otherwise break backend calls (models list, `/usage`, auth).
- **Structured headless output with `--json`.** `orbcode -p "…" --json` prints exactly one JSON object to stdout and nothing else: `{ ok, model, result, usage: { inputTokens, outputTokens, cost, totalCost }, sessionId, error }`. The `model` field reports the model actually used (so callers can verify the requested model ran), `usage` accumulates token counts and cost across the whole run, and `error` is `null` on success. The "Session saved…" line stays on stderr, so stdout is pure JSON.
- **`--require-model` to fail fast on an unknown model.** Previously an unknown `--model` warned to stderr and silently ran the default model instead. With `--require-model` (or automatically in `--json` mode) the CLI now exits non-zero with a clear error instead of running a different model than requested.
- **`--output-file <path>` for long-task results.** The agent is instructed to write its final structured result to the given path via `file_write`; headless mode reads that file and puts its contents in the envelope's `result` (falling back to the completion/text if the file is absent). This makes structured output reliable even when the model's final chat message is truncated or mangled.
- **`--verbose` event stream to stderr.** Prints tool-start/tool-end lines to stderr for observability during long agent runs, without polluting stdout.

## [6.9.5] - 2026-10-05

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@matterailab/orbcode",
"version": "6.9.5",
"version": "6.9.6",
"description": "OrbCode CLI — agentic coding in your terminal, by MatterAI",
"type": "module",
"bin": {
Expand Down Expand Up @@ -41,6 +41,7 @@
"test:readonly": "node --import tsx --test test/read-only-command.test.ts",
"test:search": "node --import tsx --test test/search-files.test.ts",
"test:rewind": "node --import tsx --test test/rewind.test.ts",
"test:headless": "node --import tsx --test test/headless.test.ts",
"test:ui": "bun test test/ui-viewport.test.tsx test/session-picker.test.tsx test/rewind-picker.test.tsx",
"prepublishOnly": "npm run typecheck && npm run build"
},
Expand Down
154 changes: 144 additions & 10 deletions src/headless.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import * as fs from "node:fs"
import * as path from "node:path"

import {
AXON_MODELS,
DEFAULT_MODEL_ID,
Expand All @@ -13,6 +16,7 @@ import {
isEidoProAxonModel,
isLumenAxonModel,
isValidAxonModel,
registerCustomModels,
usesAiSdk,
} from "./api/models.js"
import { fetchProfile } from "./auth/auth.js"
Expand All @@ -21,16 +25,64 @@ import { Agent } from "./core/agent.js"
import type { AgentEvent } from "./core/events.js"
import { McpManager } from "./mcp/manager.js"

export interface HeadlessOptions {
yolo: boolean
systemPromptOverride?: string
/** "json" emits a structured envelope; "text" (default) prints the final message. */
outputMode?: "text" | "json"
/** When true, exit non-zero if the requested model is not registered. */
requireModel?: boolean
/** Path to a file the agent should write its final structured result to. */
outputFile?: string
/** When true, print tool-start/tool-end events to stderr for observability. */
verbose?: boolean
}

/**
* Whether a tool call is the --output-file write, which headless mode approves
* even without --yolo. Both paths are resolved so `result.json`, `./result.json`
* and the absolute path all match, while any other target stays denied.
*/
export function isOutputFileWrite(outputFile: string | undefined, toolName: string, detail: string): boolean {
if (!outputFile || toolName !== "file_write" || !detail) return false
return path.resolve(detail) === path.resolve(outputFile)
}

/** Non-interactive `orbcode -p "prompt"` mode: prints the final response to stdout. */
export async function runHeadless(
prompt: string,
yolo: boolean,
systemPromptOverride?: string,
): Promise<void> {
export async function runHeadless(prompt: string, options: HeadlessOptions): Promise<void> {
const { yolo, systemPromptOverride, outputMode = "text", requireModel, outputFile, verbose } = options
const settings = loadSettings()
const token = getAuthToken(settings)

if (token) {
// --baseUrl / --apiKey: register a synthetic OpenAI-compatible model so the
// request goes through the AI SDK transport instead of the MatterAI gateway.
// Dedicated env names — MATTERAI_BASE_URL / MATTERAI_API_KEY already exist
// and override the gateway URL / auth token (see settings.ts), so reusing
// them would break backend calls (models list, /usage, auth).
const customBaseUrl = process.env.MATTERAI_LLM_BASE_URL
const customApiKey = process.env.MATTERAI_LLM_API_KEY
if (customBaseUrl) {
const modelId = process.env.MATTERAI_MODEL ?? "gpt-4o"
registerCustomModels([
{
id: modelId,
name: modelId,
description: "OpenAI-compatible model via --baseUrl",
contextWindow: 200_000,
maxOutputTokens: 32_000,
supportsImages: false,
inputPrice: 0,
outputPrice: 0,
provider: "openai-compatible",
baseUrl: customBaseUrl,
apiKey: customApiKey,
},
])
settings.model = modelId
settings.modelExplicit = true
}

if (token && !customBaseUrl) {
await fetchDynamicModels(token).catch(() => {})
}

Expand All @@ -48,6 +100,14 @@ export async function runHeadless(
if (preferred !== settings.model) settings.model = preferred
}
if (requestedModel && !isValidAxonModel(requestedModel)) {
if (requireModel || outputMode === "json") {
// In programmatic mode, silently falling back is worse than failing fast.
process.stderr.write(
`error: unknown model "${requestedModel}". ` +
`Add it under "customModels" in settings.json (with a "provider") to use it.\n`,
)
process.exit(1)
}
process.stderr.write(
`warning: unknown model "${requestedModel}"; using "${settings.model}". ` +
`Add it under "customModels" in settings.json (with a "provider") to use it.\n`,
Expand All @@ -58,7 +118,7 @@ export async function runHeadless(
// provider from the env (e.g. ANTHROPIC_API_KEY) or the model's `apiKey` —
// so they don't need a MatterAI login. Only gate on the token when the
// selected model actually goes through the MatterAI gateway.
if (!token && !usesAiSdk(getModel(settings.model))) {
if (!token && !usesAiSdk(getModel(settings.model)) && !customBaseUrl) {
console.error("Not signed in. Run `orbcode login`, set MATTERAI_TOKEN, or put an apiKey in settings.json.")
process.exit(1)
}
Expand Down Expand Up @@ -129,6 +189,12 @@ export async function runHeadless(
let textBuffer = ""
let lastText = ""
let completionResult = ""
let usageInputTokens = 0
let usageOutputTokens = 0
let usageCost = 0
let usageTotalCost = 0
let errorMessage: string | null = null

const agent = new Agent({
cwd: process.cwd(),
// May be empty for AI-SDK providers; AiSdkClient ignores it and uses the
Expand All @@ -155,19 +221,41 @@ export async function runHeadless(
case "completion":
completionResult = event.result
break
case "usage":
usageInputTokens += event.inputTokens
usageOutputTokens += event.outputTokens
usageCost += event.cost
// totalCost is already the agent's running session total.
usageTotalCost = event.totalCost
break
case "system":
// Hook messages go to stderr so stdout stays the final answer.
process.stderr.write(`${event.isError ? "hook error" : "hook"}: ${event.message}\n`)
break
case "error":
process.stderr.write(`error: ${event.message}\n`)
errorMessage = event.message
exitCode = 1
break
case "tool-start":
if (verbose) {
process.stderr.write(`[tool] ${event.name}: ${event.summary}\n`)
}
break
case "tool-end":
if (verbose) {
const status = event.isError ? "✗" : "✓"
process.stderr.write(`[${status}] ${event.name}: ${event.summary}\n`)
}
break
}
},
// In headless mode there is nobody to ask; deny unless --yolo.
// Exception: writing the --output-file artifact is always allowed,
// so read-only analysis runs can still emit a structured result.
requestApproval: async (request) => {
if (yolo) return "yes"
if (isOutputFileWrite(outputFile, request.toolName, request.detail)) return "yes"
process.stderr.write(`[denied] ${request.toolName}: ${request.summary} (pass --yolo to auto-approve)\n`)
return "no"
},
Expand All @@ -178,13 +266,59 @@ export async function runHeadless(
},
})

await agent.runTurn(prompt)
// When --output-file is set, instruct the agent to write its final result
// there via file_write. This is more reliable than parsing the chat message,
// which weaker models truncate or mangle. The path is resolved to an
// absolute path so the agent can't be confused by relative references.
const effectivePrompt = outputFile
? `${prompt}\n\n[OrbCode] When you are done, write your final answer to the file "${path.resolve(outputFile)}" using the file_write tool. Put only the final result in that file — no commentary, no markdown fences.`
: prompt

await agent.runTurn(effectivePrompt)
await agent.endSession("other")
await mcp.stop().catch(() => {})
const finalContent = completionResult || lastText || textBuffer
if (finalContent) {

// Resolve the final result: prefer the output-file artifact, then
// attempt_completion, then the last assistant text.
let finalContent = ""
if (outputFile) {
const resolvedPath = path.resolve(outputFile)
try {
finalContent = fs.readFileSync(resolvedPath, "utf8")
} catch {
// File not written; fall through to completion/text.
}
}
if (!finalContent) {
finalContent = completionResult || lastText || textBuffer
}

if (outputMode === "json") {
const envelope = {
ok: exitCode === 0,
model: settings.model,
result: finalContent.trimEnd(),
usage: {
inputTokens: usageInputTokens,
outputTokens: usageOutputTokens,
cost: usageCost,
totalCost: usageTotalCost,
},
sessionId: agent.taskId,
error: errorMessage,
}
process.stdout.write(JSON.stringify(envelope) + "\n")
} else if (finalContent) {
process.stdout.write(finalContent.trimEnd() + "\n")
}

process.stderr.write(`\nSession saved. To resume: orbcode --resume ${agent.taskId}\n`)
// process.exit() drops pending async writes (piped stdout is async on macOS),
// which would truncate `--json | jq`; exit only once both streams drain.
await Promise.all([flush(process.stdout), flush(process.stderr)])
process.exit(exitCode)
}

function flush(stream: NodeJS.WriteStream): Promise<void> {
return new Promise((resolve) => stream.write("", () => resolve()))
}
30 changes: 29 additions & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,13 @@ Usage:
orbcode plugin list list plugins installed for this project
orbcode -p "<prompt>" run a single prompt non-interactively (prints only the final response)
orbcode -p "…" --yolo non-interactive with auto-approved edits/commands
orbcode -p "…" --json non-interactive, prints a JSON envelope (model, usage, result)
orbcode -p "…" --require-model exit non-zero if the requested model is not registered
orbcode -p "…" --output-file <path> read the final result from this file instead of chat text
orbcode -p "…" --verbose print tool-start/tool-end events to stderr
orbcode --model <id> use a specific model for this run
orbcode -p "..." --baseUrl <url> headless: use an OpenAI-compatible endpoint
orbcode -p "..." --apiKey <key> headless: API key for that endpoint
orbcode --resume list previous sessions to resume
orbcode --resume <id> resume a previous session by id
orbcode -s "<prompt>" override the default system prompt (replaces it entirely;
Expand Down Expand Up @@ -199,7 +205,29 @@ async function main(): Promise<void> {
console.error("Missing prompt after -p")
process.exit(1)
}
await runHeadless(prompt, args.includes("--yolo"), systemPromptOverride)
const jsonMode = args.includes("--json")
const requireModel = args.includes("--require-model")
const verbose = args.includes("--verbose")
const outputFile = takeFlagValue(args, "output-file")

// --baseUrl / --apiKey: use an OpenAI-compatible endpoint instead of the
// MatterAI gateway. Headless-only: the TUI builds its agent from
// settings.baseUrl / getAuthToken(settings), and MATTERAI_BASE_URL /
// MATTERAI_API_KEY already feed those (gateway URL / auth token), so
// these flags use dedicated env names read only by runHeadless.
const baseUrl = takeFlagValue(args, "baseUrl") ?? takeFlagValue(args, "base-url")
if (baseUrl) process.env.MATTERAI_LLM_BASE_URL = baseUrl
const apiKey = takeFlagValue(args, "apiKey") ?? takeFlagValue(args, "api-key")
if (apiKey) process.env.MATTERAI_LLM_API_KEY = apiKey

await runHeadless(prompt, {
yolo: args.includes("--yolo"),
systemPromptOverride,
outputMode: jsonMode ? "json" : "text",
requireModel,
outputFile,
verbose,
})
return
}

Expand Down
33 changes: 33 additions & 0 deletions test/headless.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import assert from "node:assert/strict"
import test from "node:test"
import * as path from "node:path"

import { isOutputFileWrite } from "../src/headless.js"

const outputFile = path.join("out", "result.json")
const absolute = path.resolve(outputFile)

test("approves file_write to the output file however the path is spelled", () => {
assert.equal(isOutputFileWrite(outputFile, "file_write", absolute), true)
assert.equal(isOutputFileWrite(outputFile, "file_write", outputFile), true)
assert.equal(isOutputFileWrite(outputFile, "file_write", `./${outputFile}`), true)
assert.equal(isOutputFileWrite(absolute, "file_write", outputFile), true)
})

test("denies file_write to any other path", () => {
assert.equal(isOutputFileWrite(outputFile, "file_write", path.join("out", "other.json")), false)
// A substring/prefix match must not be enough.
assert.equal(isOutputFileWrite(outputFile, "file_write", `${absolute}.bak`), false)
assert.equal(isOutputFileWrite(outputFile, "file_write", path.join("out", "..", "result.json")), false)
})

test("denies tools other than file_write even on the output path", () => {
assert.equal(isOutputFileWrite(outputFile, "execute_command", absolute), false)
assert.equal(isOutputFileWrite(outputFile, "apply_diff", absolute), false)
})

test("denies everything when no output file is set or the path is empty", () => {
assert.equal(isOutputFileWrite(undefined, "file_write", absolute), false)
// An empty path would otherwise resolve to cwd.
assert.equal(isOutputFileWrite(outputFile, "file_write", ""), false)
})
Loading