diff --git a/config/scripts/benchmark-cli-error-imports.mjs b/config/scripts/benchmark-cli-error-imports.mjs
new file mode 100644
index 00000000000..a4648f84aec
--- /dev/null
+++ b/config/scripts/benchmark-cli-error-imports.mjs
@@ -0,0 +1,121 @@
+import assert from 'node:assert/strict'
+import { createRequire } from 'node:module'
+import { existsSync, realpathSync } from 'node:fs'
+import { delimiter, join, resolve } from 'node:path'
+
+// Emit each revision with tsc -p config/tsconfig.cli.json --outDir
--composite false --incremental false.
+// Run: node config/scripts/benchmark-cli-error-imports.mjs
+const [beforeDir, afterDir] = process.argv.slice(2)
+assert.ok(beforeDir && afterDir, 'Pass distinct before and after TypeScript output directories.')
+assert.notEqual(
+ realpathSync(beforeDir),
+ realpathSync(afterDir),
+ 'Do not compare a build to itself.'
+)
+const entries = {
+ before: join(resolve(beforeDir), 'cli', 'index.js'),
+ after: join(resolve(afterDir), 'cli', 'index.js')
+}
+for (const entry of Object.values(entries)) {
+ assert.ok(existsSync(entry), `Missing emitted CLI: ${entry}`)
+}
+
+const { runProcessSync } = createRequire(import.meta.url)(
+ join(resolve(afterDir), 'shared', 'child-process', 'run-process.js')
+)
+
+const child = String.raw`
+ const { performance } = require('node:perf_hooks')
+ const { writeSync } = require('node:fs')
+ const { createHash } = require('node:crypto')
+ const { basename } = require('node:path')
+ let stdout = '', stderr = ''
+ process.stdout.write = (text) => { stdout += text; return true }
+ process.stderr.write = (text) => { stderr += text; return true }
+ const started = performance.now()
+ const cli = require(process.argv[1])
+ const importMs = performance.now() - started
+ cli.main(JSON.parse(process.argv[2])).then(() => {
+ const totalMs = performance.now() - started
+ const modules = Object.keys(require.cache)
+ writeSync(1, JSON.stringify({
+ importMs, totalMs, modules: modules.length,
+ featureFormatters: modules.filter((file) => ['browser', 'terminal', 'project', 'automation', 'workspace', 'computer'].some((name) => basename(file) === name + '-format.js')),
+ stdout: createHash('sha256').update(stdout).digest('hex'),
+ stderr: createHash('sha256').update(stderr).digest('hex'),
+ exitCode: process.exitCode || 0
+ }))
+ process.exitCode = 0
+ }).catch((error) => { writeSync(2, String(error)); process.exitCode = 1 })
+`
+const cases = [
+ ['--help'],
+ ['help', 'terminal', 'read'],
+ ['does-not-exist'],
+ ['computer', 'click', '--does-not-exist'],
+ ['does-not-exist', '--json']
+]
+const median = (values) => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]
+const summarize = (samples) => ({
+ importMs: median(samples.map((sample) => sample.importMs)),
+ totalMs: median(samples.map((sample) => sample.totalMs)),
+ modules: samples[0].modules
+})
+const rows = []
+for (const args of cases) {
+ const samples = { before: [], after: [] }
+ let expected
+ for (let run = 0; run < 22; run++) {
+ for (const variant of run % 2 ? ['after', 'before'] : ['before', 'after']) {
+ const result = runProcessSync({
+ program: process.execPath,
+ args: ['-e', child, entries[variant], JSON.stringify(args)],
+ timeoutMs: 30_000,
+ env: {
+ ...process.env,
+ NODE_PATH: [resolve('node_modules'), process.env.NODE_PATH]
+ .filter(Boolean)
+ .join(delimiter)
+ }
+ })
+ assert.equal(result.timedOut, false, 'CLI child timed out.')
+ assert.equal(result.code, 0, result.stderr)
+ const sample = JSON.parse(result.stdout)
+ const output = { stdout: sample.stdout, stderr: sample.stderr, exitCode: sample.exitCode }
+ expected ??= output
+ assert.deepEqual(output, expected, `${variant} output changed for ${args.join(' ')}`)
+ if (variant === 'after') {
+ assert.deepEqual(
+ sample.featureFormatters,
+ [],
+ 'Help and syntax errors must skip feature formatters.'
+ )
+ }
+ if (run >= 2) {
+ samples[variant].push(sample)
+ }
+ }
+ }
+ assert.ok(samples.after[0].modules < samples.before[0].modules, 'Expected fewer loaded modules.')
+ rows.push({
+ args,
+ before: summarize(samples.before),
+ after: summarize(samples.after),
+ output: expected,
+ samples
+ })
+}
+console.log(
+ JSON.stringify(
+ {
+ node: process.version,
+ platform: process.platform,
+ measurement:
+ 'Fresh-process import + main; excludes process creation; warmed filesystem; 2 warmups and 20 samples per variant, alternating order.',
+ entries,
+ rows
+ },
+ null,
+ 2
+ )
+)
diff --git a/config/scripts/cli-runtime-client-deferral-equivalence.mjs b/config/scripts/cli-runtime-client-deferral-equivalence.mjs
index f443bf3b9f9..a231b5ba75a 100644
--- a/config/scripts/cli-runtime-client-deferral-equivalence.mjs
+++ b/config/scripts/cli-runtime-client-deferral-equivalence.mjs
@@ -2,7 +2,7 @@
// Equivalence check for deferring the RuntimeClient module graph in the CLI.
//
// Builds the CLI twice with the REAL tsc emit — once from the working tree and
-// once with the seven touched files restored from git HEAD~ (the pre-deferral
+// once with the touched files restored from git HEAD~ (the pre-deferral
// implementation) — then compares stdout, stderr and exit code BYTE FOR BYTE
// across a matrix of invocations.
//
@@ -13,7 +13,7 @@
//
// Usage: node config/scripts/cli-runtime-client-deferral-equivalence.mjs [--baseline ]
import { execFileSync, spawnSync } from 'node:child_process'
-import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'
+import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -21,8 +21,11 @@ const REPO = fileURLToPath(new URL('../..', import.meta.url))
// The files this change touches. Restoring exactly these from the baseline rev
// reconstructs the old implementation without disturbing anything else.
+// Files absent at the baseline (e.g. cli-error.ts, split out of format.ts
+// later) are removed for the baseline build and put back afterwards.
const TOUCHED = [
'src/cli/args.ts',
+ 'src/cli/cli-error.ts',
'src/cli/dispatch.ts',
'src/cli/flags.ts',
'src/cli/format.ts',
@@ -72,12 +75,16 @@ function buildTree(label, baselineRev) {
if (baselineRev) {
for (const file of TOUCHED) {
const path = join(REPO, file)
- restored.push([path, readFileSync(path)])
- const old = execFileSync('git', ['show', `${baselineRev}:${file}`], {
+ restored.push([path, existsSync(path) ? readFileSync(path) : null])
+ const old = spawnSync('git', ['show', `${baselineRev}:${file}`], {
cwd: REPO,
maxBuffer: 64 * 1024 * 1024
})
- writeFileSync(path, old)
+ if (old.status === 0) {
+ writeFileSync(path, old.stdout)
+ } else {
+ rmSync(path, { force: true })
+ }
}
}
execFileSync(
@@ -97,7 +104,11 @@ function buildTree(label, baselineRev) {
)
} finally {
for (const [path, contents] of restored) {
- writeFileSync(path, contents)
+ if (contents === null) {
+ rmSync(path, { force: true })
+ } else {
+ writeFileSync(path, contents)
+ }
}
}
return join(outDir, 'cli/index.js')
diff --git a/src/cli/cli-error.ts b/src/cli/cli-error.ts
new file mode 100644
index 00000000000..6a87f149079
--- /dev/null
+++ b/src/cli/cli-error.ts
@@ -0,0 +1,144 @@
+import { computerUseErrorRecoveryData } from '../shared/computer-use-error-recovery'
+import {
+ matchAutomationOwnerConflict,
+ stripAutomationOwnerConflictCode
+} from '../shared/automation-owner-conflict'
+import { automationOwnerConflictRecovery } from './automation-owner-conflict-recovery'
+import type { RuntimeRpcFailure } from './runtime-client'
+import { RuntimeClientError, RuntimeRpcFailureError } from './runtime/types'
+
+type CliErrorContext = {
+ commandPath?: readonly string[]
+}
+
+export function formatCliError(error: unknown, context: CliErrorContext = {}): string {
+ const message = error instanceof Error ? error.message : String(error)
+ if (error instanceof RuntimeClientError && error.code === 'runtime_unavailable') {
+ if (hasOrchestrationRequestId(error.data)) {
+ return message
+ }
+ return `${message}\nOrca is not running. Run 'orca open' first.`
+ }
+ // Why: error-specific recovery must win over the generic computer fallback.
+ // Classified from the whole error, not just `.code`: a hop that flattens the class leaves only the token.
+ const conflict = automationOwnerConflictRecovery(matchAutomationOwnerConflict(error))
+ if (conflict) {
+ return formatMessageWithNextSteps(stripAutomationOwnerConflictCode(message), conflict.nextSteps)
+ }
+ if (error instanceof RuntimeClientError) {
+ const nextSteps = nextStepsFromData(error.data)
+ if (nextSteps.length > 0) {
+ return formatMessageWithNextSteps(message, nextSteps)
+ }
+ if (error.code === 'invalid_argument' && context.commandPath?.[0] === 'computer') {
+ return formatMessageWithNextSteps(
+ message,
+ computerUseErrorRecoveryData('invalid_argument')?.nextSteps ?? []
+ )
+ }
+ }
+ if (
+ error instanceof RuntimeRpcFailureError &&
+ error.response.error.code === 'runtime_unavailable'
+ ) {
+ return `${message}\nOrca is not running. Run 'orca open' first.`
+ }
+ if (error instanceof RuntimeRpcFailureError) {
+ return formatMessageWithNextSteps(message, nextStepsFromData(error.response.error.data))
+ }
+ return message
+}
+
+function hasOrchestrationRequestId(data: unknown): boolean {
+ return (
+ data !== null &&
+ typeof data === 'object' &&
+ typeof (data as { orchestrationRequestId?: unknown }).orchestrationRequestId === 'string'
+ )
+}
+
+export function reportCliError(error: unknown, json: boolean, context: CliErrorContext = {}): void {
+ if (json) {
+ if (error instanceof RuntimeRpcFailureError) {
+ console.log(JSON.stringify(withAutomationOwnerConflictRecovery(error.response), null, 2))
+ } else {
+ const response: RuntimeRpcFailure = {
+ id: 'local',
+ ok: false,
+ error: {
+ code:
+ matchAutomationOwnerConflict(error) ??
+ (error instanceof RuntimeClientError ? error.code : 'runtime_error'),
+ message: stripAutomationOwnerConflictCode(
+ error instanceof Error ? error.message : String(error)
+ ),
+ data: localCliErrorData(error, context)
+ },
+ _meta: {
+ runtimeId: null
+ }
+ }
+ console.log(JSON.stringify(response, null, 2))
+ }
+ } else {
+ console.error(formatCliError(error, context))
+ }
+}
+
+/** Machine-readable half of the same recovery the human message carries. */
+function withAutomationOwnerConflictRecovery(response: RuntimeRpcFailure): RuntimeRpcFailure {
+ const code = matchAutomationOwnerConflict(response)
+ const conflict = automationOwnerConflictRecovery(code)
+ if (!conflict || !code) {
+ return response
+ }
+ return {
+ ...response,
+ error: {
+ ...response.error,
+ // Restores the classification a flattening hop dropped, so --json consumers read the conflict, not the transport.
+ code,
+ message: stripAutomationOwnerConflictCode(response.error.message),
+ data: response.error.data ?? conflict
+ }
+ }
+}
+
+function formatMessageWithNextSteps(message: string, nextSteps: readonly string[]): string {
+ if (nextSteps.length === 0) {
+ return message
+ }
+ return `${message}\n${nextSteps.map((step) => `Next step: ${step}`).join('\n')}`
+}
+
+function nextStepsFromData(data: unknown): string[] {
+ if (
+ data &&
+ typeof data === 'object' &&
+ Array.isArray((data as { nextSteps?: unknown }).nextSteps)
+ ) {
+ return (data as { nextSteps: unknown[] }).nextSteps.filter(
+ (step): step is string => typeof step === 'string'
+ )
+ }
+ return []
+}
+
+function localCliErrorData(error: unknown, context: CliErrorContext): unknown {
+ // Why: error-specific recovery must win over the generic computer fallback.
+ if (error instanceof RuntimeClientError && error.data !== undefined) {
+ return error.data
+ }
+ const conflict = automationOwnerConflictRecovery(matchAutomationOwnerConflict(error))
+ if (conflict) {
+ return conflict
+ }
+ if (
+ error instanceof RuntimeClientError &&
+ error.code === 'invalid_argument' &&
+ context.commandPath?.[0] === 'computer'
+ ) {
+ return computerUseErrorRecoveryData('invalid_argument')
+ }
+ return undefined
+}
diff --git a/src/cli/format.ts b/src/cli/format.ts
index 1487a69eea0..dd6b7b739c7 100644
--- a/src/cli/format.ts
+++ b/src/cli/format.ts
@@ -1,13 +1,8 @@
import type { CliStatusResult } from '../shared/runtime-types'
-import { computerUseErrorRecoveryData } from '../shared/computer-use-error-recovery'
-import {
- matchAutomationOwnerConflict,
- stripAutomationOwnerConflictCode
-} from '../shared/automation-owner-conflict'
-import { automationOwnerConflictRecovery } from './automation-owner-conflict-recovery'
import { prepareComputerCliJsonResult } from './computer-format'
-import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client'
-import { RuntimeClientError, RuntimeRpcFailureError } from './runtime/types'
+import type { RuntimeRpcSuccess } from './runtime-client'
+
+export { formatCliError, reportCliError } from './cli-error'
export {
formatBrowserProfileList,
@@ -67,10 +62,6 @@ export {
formatWorktreeShow
} from './workspace-format'
-type CliErrorContext = {
- commandPath?: readonly string[]
-}
-
export function printResult(
response: RuntimeRpcSuccess,
json: boolean,
@@ -83,138 +74,6 @@ export function printResult(
console.log(formatter(response.result))
}
-export function formatCliError(error: unknown, context: CliErrorContext = {}): string {
- const message = error instanceof Error ? error.message : String(error)
- if (error instanceof RuntimeClientError && error.code === 'runtime_unavailable') {
- if (hasOrchestrationRequestId(error.data)) {
- return message
- }
- return `${message}\nOrca is not running. Run 'orca open' first.`
- }
- // Why: error-specific recovery must win over the generic computer fallback.
- // Classified from the whole error, not just `.code`: a hop that flattens the class leaves only the token.
- const conflict = automationOwnerConflictRecovery(matchAutomationOwnerConflict(error))
- if (conflict) {
- return formatMessageWithNextSteps(stripAutomationOwnerConflictCode(message), conflict.nextSteps)
- }
- if (error instanceof RuntimeClientError) {
- const nextSteps = nextStepsFromData(error.data)
- if (nextSteps.length > 0) {
- return formatMessageWithNextSteps(message, nextSteps)
- }
- if (error.code === 'invalid_argument' && context.commandPath?.[0] === 'computer') {
- return formatMessageWithNextSteps(
- message,
- computerUseErrorRecoveryData('invalid_argument')?.nextSteps ?? []
- )
- }
- }
- if (
- error instanceof RuntimeRpcFailureError &&
- error.response.error.code === 'runtime_unavailable'
- ) {
- return `${message}\nOrca is not running. Run 'orca open' first.`
- }
- if (error instanceof RuntimeRpcFailureError) {
- return formatMessageWithNextSteps(message, nextStepsFromData(error.response.error.data))
- }
- return message
-}
-
-function hasOrchestrationRequestId(data: unknown): boolean {
- return (
- data !== null &&
- typeof data === 'object' &&
- typeof (data as { orchestrationRequestId?: unknown }).orchestrationRequestId === 'string'
- )
-}
-
-export function reportCliError(error: unknown, json: boolean, context: CliErrorContext = {}): void {
- if (json) {
- if (error instanceof RuntimeRpcFailureError) {
- console.log(JSON.stringify(withAutomationOwnerConflictRecovery(error.response), null, 2))
- } else {
- const response: RuntimeRpcFailure = {
- id: 'local',
- ok: false,
- error: {
- code:
- matchAutomationOwnerConflict(error) ??
- (error instanceof RuntimeClientError ? error.code : 'runtime_error'),
- message: stripAutomationOwnerConflictCode(
- error instanceof Error ? error.message : String(error)
- ),
- data: localCliErrorData(error, context)
- },
- _meta: {
- runtimeId: null
- }
- }
- console.log(JSON.stringify(response, null, 2))
- }
- } else {
- console.error(formatCliError(error, context))
- }
-}
-
-/** Machine-readable half of the same recovery the human message carries. */
-function withAutomationOwnerConflictRecovery(response: RuntimeRpcFailure): RuntimeRpcFailure {
- const code = matchAutomationOwnerConflict(response)
- const conflict = automationOwnerConflictRecovery(code)
- if (!conflict || !code) {
- return response
- }
- return {
- ...response,
- error: {
- ...response.error,
- // Restores the classification a flattening hop dropped, so --json consumers read the conflict, not the transport.
- code,
- message: stripAutomationOwnerConflictCode(response.error.message),
- data: response.error.data ?? conflict
- }
- }
-}
-
-function formatMessageWithNextSteps(message: string, nextSteps: readonly string[]): string {
- if (nextSteps.length === 0) {
- return message
- }
- return `${message}\n${nextSteps.map((step) => `Next step: ${step}`).join('\n')}`
-}
-
-function nextStepsFromData(data: unknown): string[] {
- if (
- data &&
- typeof data === 'object' &&
- Array.isArray((data as { nextSteps?: unknown }).nextSteps)
- ) {
- return (data as { nextSteps: unknown[] }).nextSteps.filter(
- (step): step is string => typeof step === 'string'
- )
- }
- return []
-}
-
-function localCliErrorData(error: unknown, context: CliErrorContext): unknown {
- // Why: error-specific recovery must win over the generic computer fallback.
- if (error instanceof RuntimeClientError && error.data !== undefined) {
- return error.data
- }
- const conflict = automationOwnerConflictRecovery(matchAutomationOwnerConflict(error))
- if (conflict) {
- return conflict
- }
- if (
- error instanceof RuntimeClientError &&
- error.code === 'invalid_argument' &&
- context.commandPath?.[0] === 'computer'
- ) {
- return computerUseErrorRecoveryData('invalid_argument')
- }
- return undefined
-}
-
export type HostListEntry = {
kind: 'local' | 'ssh' | 'environment'
name: string
diff --git a/src/cli/index.ts b/src/cli/index.ts
index 9389113b195..a0e1354307f 100644
--- a/src/cli/index.ts
+++ b/src/cli/index.ts
@@ -15,7 +15,7 @@ import {
resolveHostFlagEnvironmentId
} from './execution-host-flag'
import { listSshTargets } from './host-selector-alternatives'
-import { reportCliError } from './format'
+import { reportCliError } from './cli-error'
import { printHelp } from './help'
import type { RuntimeClient } from './runtime-client'
import { COMMAND_SPECS } from './specs'
diff --git a/src/cli/runtime-client-deferral.test.ts b/src/cli/runtime-client-deferral.test.ts
index 658cc60f0a4..5fcdf8b9686 100644
--- a/src/cli/runtime-client-deferral.test.ts
+++ b/src/cli/runtime-client-deferral.test.ts
@@ -84,14 +84,12 @@ describe('RuntimeClient module-graph deferral', () => {
process.exitCode = 0
})
- // Why: the whole point of the change. These six modules load on EVERY
- // invocation, so a value-import of the barrel from any of them drags the
- // RuntimeClient graph (zod, ws, tweetnacl) back onto the --help path.
+ // These eager modules must not pull the RuntimeClient dependency graph into help.
it.each([
'args.ts',
'flags.ts',
'dispatch.ts',
- 'format.ts',
+ 'cli-error.ts',
'selectors.ts',
'execution-host-flag.ts'
])('%s imports error classes from ./runtime/types, not the barrel', (file) => {
@@ -110,6 +108,7 @@ describe('RuntimeClient module-graph deferral', () => {
expect(source).toContain("import type { RuntimeClient } from './runtime-client'")
expect(source).not.toMatch(/^import \{[^}]*RuntimeClient[^}]*\} from '\.\/runtime-client'/m)
expect(source).toContain("await import('./runtime-client.js')")
+ expect(source).toContain("import { reportCliError } from './cli-error'")
})
it('constructs no client for --help', async () => {