diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml
index 3f505ca75d9..26de7544dd2 100644
--- a/.github/workflows/release-cut.yml
+++ b/.github/workflows/release-cut.yml
@@ -894,7 +894,7 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Publish release artifacts (Linux)
- if: matrix.platform == 'linux'
+ if: matrix.platform == 'linux-x64' || matrix.platform == 'linux-arm64'
uses: nick-fields/retry@v4
with:
timeout_minutes: 30
diff --git a/README.md b/README.md
index b11d94924fc..bb86cc4c9e5 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
-[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [Download the APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk)
+- **Android:** [Download the APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk)
---
diff --git a/config/scripts/benchmark-artifact-comparison.test.mjs b/config/scripts/benchmark-artifact-comparison.test.mjs
new file mode 100644
index 00000000000..d7700deda04
--- /dev/null
+++ b/config/scripts/benchmark-artifact-comparison.test.mjs
@@ -0,0 +1,440 @@
+import { spawnSync } from 'node:child_process'
+import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+ compareBenchmarkArtifacts,
+ formatBenchmarkComparisonMarkdown,
+ parseBenchmarkComparisonArgs
+} from './compare-benchmark-artifacts.mjs'
+
+const scriptPath = 'config/scripts/compare-benchmark-artifacts.mjs'
+const tempDirs = []
+
+function makeTempDir() {
+ const dir = mkdtempSync(join(tmpdir(), 'orca-benchmark-comparison-'))
+ tempDirs.push(dir)
+ return dir
+}
+
+function writeArtifact(dir, name, artifact) {
+ const artifactPath = join(dir, name)
+ writeFileSync(artifactPath, JSON.stringify(artifact))
+ return artifactPath
+}
+
+function comparePaths(baselinePath, candidatePath, extra = {}) {
+ return compareBenchmarkArtifacts({
+ baselinePath,
+ candidatePath,
+ now: () => new Date('2026-06-21T12:00:00.000Z'),
+ title: 'Test Compare',
+ ...extra
+ })
+}
+
+afterEach(() => {
+ while (tempDirs.length > 0) {
+ rmSync(tempDirs.pop(), { force: true, recursive: true })
+ }
+})
+
+describe('benchmark artifact comparison', () => {
+ it('parses required CLI flags and rejects missing paths', () => {
+ expect(() => parseBenchmarkComparisonArgs(['--baseline'])).toThrow('--baseline requires a path')
+ expect(() => parseBenchmarkComparisonArgs(['--candidate'])).toThrow(
+ '--candidate requires a path'
+ )
+ expect(() => parseBenchmarkComparisonArgs(['--candidate', 'candidate.json'])).toThrow(
+ 'Usage: node config/scripts/compare-benchmark-artifacts.mjs --baseline --candidate [--title ] [--output ] [--json-output ] [--higher-is-better ...]'
+ )
+ })
+
+ it('compares startup-style summary median metrics and skips null candidates', () => {
+ const dir = makeTempDir()
+ const baselinePath = writeArtifact(dir, 'baseline.json', {
+ label: 'baseline',
+ summaryMedianMs: {
+ missingLater: 5,
+ spawnToAppReady: 25,
+ totalToDidFinishLoad: 100
+ }
+ })
+ const candidatePath = writeArtifact(dir, 'candidate.json', {
+ label: 'candidate',
+ summaryMedianMs: {
+ missingLater: null,
+ spawnToAppReady: 30,
+ totalToDidFinishLoad: 40
+ }
+ })
+
+ const comparison = comparePaths(baselinePath, candidatePath)
+
+ expect(comparison.schemaVersion).toBe(1)
+ expect(comparison.createdAt).toBe('2026-06-21T12:00:00.000Z')
+ expect(comparison.baseline.label).toBe('baseline')
+ expect(comparison.candidate.label).toBe('candidate')
+ expect(
+ comparison.metrics.find((metric) => metric.key === 'totalToDidFinishLoad')
+ ).toMatchObject({
+ absoluteDelta: -60,
+ baseline: 100,
+ candidate: 40,
+ percentDelta: -60,
+ status: 'improved',
+ unit: 'ms'
+ })
+ expect(comparison.metrics.find((metric) => metric.key === 'spawnToAppReady')).toMatchObject({
+ status: 'regressed'
+ })
+ expect(comparison.skippedMetrics).toContainEqual({
+ key: 'missingLater',
+ reason: 'missing candidate metric'
+ })
+ })
+
+ it('compares numeric Playwright annotation metrics and omits metadata fields', () => {
+ const dir = makeTempDir()
+ const baselinePath = writeArtifact(dir, 'baseline-playwright.json', {
+ suites: [
+ {
+ suites: [
+ {
+ specs: [
+ {
+ tests: [
+ {
+ annotations: [
+ {
+ type: 'opencode-scale-same-workspace-50',
+ description:
+ 'panes=50 frames=60 median=80.0ms worst=120.0ms rendererQueuedChars=1000 samples=1,2'
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ })
+ const candidatePath = writeArtifact(dir, 'candidate-playwright.json', {
+ suites: [
+ {
+ specs: [
+ {
+ tests: [
+ {
+ annotations: [
+ {
+ type: 'opencode-scale-same-workspace-50',
+ description:
+ 'panes=50 frames=60 median=60.0ms worst=100.0ms rendererQueuedChars=800 samples=1,2'
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ })
+
+ const comparison = comparePaths(baselinePath, candidatePath)
+ const metricKeys = comparison.metrics.map((metric) => metric.key)
+
+ expect(metricKeys).toContain('opencode-scale-same-workspace-50.median')
+ expect(metricKeys).toContain('opencode-scale-same-workspace-50.rendererQueuedChars')
+ expect(metricKeys).not.toContain('opencode-scale-same-workspace-50.panes')
+ expect(metricKeys).not.toContain('opencode-scale-same-workspace-50.frames')
+ expect(metricKeys).not.toContain('opencode-scale-same-workspace-50.samples')
+ expect(
+ comparison.metrics.find((metric) => metric.key === 'opencode-scale-same-workspace-50.median')
+ ).toMatchObject({ status: 'improved', unit: 'ms' })
+ })
+
+ it('aggregates duplicate Playwright scenario metrics before comparison', () => {
+ const dir = makeTempDir()
+ const baselinePath = writeArtifact(dir, 'baseline-playwright-duplicates.json', {
+ suites: [
+ {
+ specs: [
+ {
+ tests: [
+ {
+ annotations: [
+ {
+ type: 'opencode-duplicate',
+ description: 'median=80.0ms rendererQueuedChars=1000'
+ },
+ {
+ type: 'opencode-duplicate',
+ description: 'median=100.0ms rendererQueuedChars=1400'
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ })
+ const candidatePath = writeArtifact(dir, 'candidate-playwright-duplicates.json', {
+ suites: [
+ {
+ specs: [
+ {
+ tests: [
+ {
+ annotations: [
+ {
+ type: 'opencode-duplicate',
+ description: 'median=60.0ms rendererQueuedChars=800'
+ },
+ {
+ type: 'opencode-duplicate',
+ description: 'median=70.0ms rendererQueuedChars=1000'
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ })
+
+ const comparison = comparePaths(baselinePath, candidatePath)
+ const duplicateMedianMetrics = comparison.metrics.filter(
+ (metric) => metric.key === 'opencode-duplicate.median'
+ )
+
+ expect(duplicateMedianMetrics).toHaveLength(1)
+ expect(duplicateMedianMetrics[0]).toMatchObject({
+ absoluteDelta: -25,
+ baseline: 90,
+ candidate: 65,
+ percentDelta: -27.8,
+ status: 'improved',
+ unit: 'ms'
+ })
+ expect(
+ comparison.metrics.filter((metric) => metric.key === 'opencode-duplicate.rendererQueuedChars')
+ ).toHaveLength(1)
+ })
+
+ it('skips unit mismatches instead of comparing incompatible metrics', () => {
+ const dir = makeTempDir()
+ const baselinePath = writeArtifact(dir, 'baseline-playwright-units.json', {
+ suites: [
+ {
+ specs: [
+ {
+ tests: [
+ {
+ annotations: [
+ {
+ type: 'opencode-units',
+ description: 'median=80.0ms rendererQueuedChars=1000'
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ })
+ const candidatePath = writeArtifact(dir, 'candidate-playwright-units.json', {
+ suites: [
+ {
+ specs: [
+ {
+ tests: [
+ {
+ annotations: [
+ {
+ type: 'opencode-units',
+ description: 'median=60 rendererQueuedChars=800'
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ })
+
+ const comparison = comparePaths(baselinePath, candidatePath)
+
+ expect(comparison.metrics.map((metric) => metric.key)).toEqual([
+ 'opencode-units.rendererQueuedChars'
+ ])
+ expect(comparison.skippedMetrics).toContainEqual({
+ key: 'opencode-units.median',
+ reason: 'unit mismatch (ms vs count)'
+ })
+ })
+
+ it('supports higher-is-better metrics for generic summary artifacts', () => {
+ const dir = makeTempDir()
+ const baselinePath = writeArtifact(dir, 'generic-baseline.json', {
+ summary: {
+ totalBytes: 2048,
+ totalCpuPercent: { mean: 80 },
+ throughput: 10
+ }
+ })
+ const candidatePath = writeArtifact(dir, 'generic-candidate.json', {
+ summary: {
+ totalBytes: 1024,
+ totalCpuPercent: { mean: 70 },
+ throughput: 12
+ }
+ })
+
+ const comparison = comparePaths(baselinePath, candidatePath, {
+ higherIsBetter: new Set(['summary.throughput'])
+ })
+
+ expect(comparison.metrics.find((metric) => metric.key === 'summary.throughput')).toMatchObject({
+ direction: 'higher-is-better',
+ status: 'improved'
+ })
+ expect(comparison.metrics.find((metric) => metric.key === 'summary.totalBytes')).toMatchObject({
+ unit: 'bytes'
+ })
+ expect(
+ comparison.metrics.find((metric) => metric.key === 'summary.totalCpuPercent.mean')
+ ).toMatchObject({
+ unit: '%'
+ })
+ })
+
+ it('writes Markdown and JSON reports from the CLI while printing Markdown', () => {
+ const dir = makeTempDir()
+ const baselinePath = writeArtifact(dir, 'baseline.json', {
+ label: 'baseline',
+ summaryMedianMs: { totalToDidFinishLoad: 100 }
+ })
+ const candidatePath = writeArtifact(dir, 'candidate.json', {
+ label: 'candidate',
+ summaryMedianMs: { totalToDidFinishLoad: 40 }
+ })
+ const markdownPath = join(dir, 'nested', 'comparison.md')
+ const jsonPath = join(dir, 'nested', 'comparison.json')
+
+ const result = spawnSync(
+ process.execPath,
+ [
+ scriptPath,
+ '--baseline',
+ baselinePath,
+ '--candidate',
+ candidatePath,
+ '--title',
+ 'Test Compare',
+ '--output',
+ markdownPath,
+ '--json-output',
+ jsonPath
+ ],
+ { cwd: process.cwd(), encoding: 'utf8' }
+ )
+
+ expect(result.status).toBe(0)
+ expect(result.stdout).toContain('| Metric | Baseline | Candidate | Delta | Delta % | Result |')
+ expect(result.stdout).toContain(
+ '| totalToDidFinishLoad | 100.0ms | 40.0ms | -60.0ms | -60.0% | improved |'
+ )
+ expect(existsSync(markdownPath)).toBe(true)
+ expect(existsSync(jsonPath)).toBe(true)
+ expect(JSON.parse(readFileSync(jsonPath, 'utf8')).schemaVersion).toBe(1)
+ })
+
+ it('redacts absolute input paths from generated reports', () => {
+ const dir = makeTempDir()
+ const baselinePath = writeArtifact(dir, 'absolute-baseline.json', {
+ label: 'baseline',
+ summaryMedianMs: { totalToDidFinishLoad: 100 }
+ })
+ const candidatePath = writeArtifact(dir, 'absolute-candidate.json', {
+ label: 'candidate',
+ summaryMedianMs: { totalToDidFinishLoad: 40 }
+ })
+ const markdownPath = join(dir, 'comparison.md')
+ const jsonPath = join(dir, 'comparison.json')
+
+ const result = spawnSync(
+ process.execPath,
+ [
+ scriptPath,
+ '--baseline',
+ baselinePath,
+ '--candidate',
+ candidatePath,
+ '--output',
+ markdownPath,
+ '--json-output',
+ jsonPath
+ ],
+ { cwd: process.cwd(), encoding: 'utf8' }
+ )
+ const json = JSON.parse(readFileSync(jsonPath, 'utf8'))
+ const markdown = readFileSync(markdownPath, 'utf8')
+
+ expect(result.status).toBe(0)
+ expect(json.baseline.path).toBe('absolute-baseline.json')
+ expect(json.candidate.path).toBe('absolute-candidate.json')
+ expect(markdown).not.toContain(dir)
+ expect(result.stdout).not.toContain(dir)
+ })
+
+ it('escapes artifact-controlled Markdown fields in reports', () => {
+ const markdown = formatBenchmarkComparisonMarkdown({
+ title: 'Compare\n[Injected](https://example.test)',
+ createdAt: '2026-06-21T12:00:00.000Z',
+ baseline: { label: 'base\n |
@@ -226,7 +226,7 @@ yay -S stably-orca-bin
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk)
+- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk)
---
diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md
index f591da62b2f..665bebbfe3f 100644
--- a/docs/readme/README.ja.md
+++ b/docs/readme/README.ja.md
@@ -35,7 +35,7 @@
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
-[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
@@ -226,7 +226,7 @@ yay -S stably-orca-bin
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk)
+- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk)
---
diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md
index 105dd118bb3..5771cf35b49 100644
--- a/docs/readme/README.ko.md
+++ b/docs/readme/README.ko.md
@@ -35,7 +35,7 @@
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
-[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
@@ -226,7 +226,7 @@ yay -S stably-orca-bin
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk)
+- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk)
---
diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md
index c1f3343ca56..05c3f462019 100644
--- a/docs/readme/README.zh-CN.md
+++ b/docs/readme/README.zh-CN.md
@@ -35,7 +35,7 @@
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
-[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
@@ -226,7 +226,7 @@ yay -S stably-orca-bin
与桌面应用配对,用手机监控并指挥你的智能体。
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk)
+- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk)
---
diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx
index 7be0e3894ea..542dbf53a47 100644
--- a/mobile/app/h/[hostId]/session/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx
@@ -4915,12 +4915,14 @@ export default function SessionScreen() {
autoCorrect={false}
spellCheck={false}
smartInsertDelete={false}
+ // Why: iOS textContentType wins over autoComplete and can
+ // narrow the keyboard surface; keep IME switching available.
+ autoComplete="off"
keyboardType={getTerminalLiveInputKeyboardType(Platform.OS)}
returnKeyType="default"
blurOnSubmit={false}
editable={canSend}
importantForAutofill="no"
- textContentType="none"
/>
) : (
@@ -4946,6 +4948,9 @@ export default function SessionScreen() {
autoCorrect={autocompleteEnabled}
spellCheck={autocompleteEnabled}
smartInsertDelete={false}
+ // Why: terminal commands are not autofill content, but the
+ // keyboard must stay default so non-Latin IMEs remain selectable.
+ autoComplete="off"
keyboardType={getTerminalCommandKeyboardType(
Platform.OS,
autocompleteEnabled
diff --git a/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts b/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts
index 412e9db664a..ac6ee0ff91f 100644
--- a/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts
+++ b/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts
@@ -11,4 +11,9 @@ describe('terminal iOS IME keyboard', () => {
expect(sessionRouteSource).not.toContain("'ascii-capable'")
expect(sessionRouteSource).not.toContain('"ascii-capable"')
})
+
+ it('does not put terminal keyboard capture behind iOS textContentType semantics', () => {
+ expect(sessionRouteSource).not.toContain('textContentType="none"')
+ expect(sessionRouteSource).toContain('autoComplete="off"')
+ })
})
diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift
index 86ea4dbc37e..da6c4d34cda 100644
--- a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift
+++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift
@@ -1508,6 +1508,7 @@ private final class TreeRenderer {
var focusedElementId: Int?
var truncated = false
var maxDepthReached = false
+ private let reader = AXSnapshotReader()
private var nextIndex = 0
static let maxNodes = 1200
static let maxDepth = 64
@@ -1530,22 +1531,26 @@ private final class TreeRenderer {
}
guard !ancestors.contains(where: { CFEqual($0, element) }) else { return }
- let role = stringAttribute(element, kAXRoleAttribute as String) ?? "AXUnknown"
- let children = primaryChildren(element, role: role, windowBounds: windowBounds)
- let value = valueString(element)
- let placeholder = placeholderString(element)
- let rawActions = actions(element)
- let rowSummary = rowTextSummary(element, role: role)
- let linkText = role == "AXLink" ? descendantTextSnippets(element, limit: 2, maxDepth: 3).first : nil
+ let role = reader.stringAttribute(element, kAXRoleAttribute as String) ?? "AXUnknown"
+ let children = reader.primaryChildren(element, role: role, windowBounds: windowBounds)
+ let value = reader.valueString(element, role: role)
+ let placeholder = reader.placeholderString(element)
+ let rawActions = reader.actions(element)
+ let rowSummary = reader.rowTextSummary(element, role: role)
+ let roleDescription = reader.stringAttribute(element, kAXRoleDescriptionAttribute as String)
+ let title = reader.stringAttribute(element, kAXTitleAttribute as String)
+ let label = reader.stringAttribute(element, kAXDescriptionAttribute as String)
+ let url = reader.stringAttribute(element, kAXURLAttribute as String)
+ let linkText = role == "AXLink" ? reader.descendantTextSnippets(element, limit: 2, maxDepth: 3).first : nil
let baseNode = SnapshotRenderNode(
role: role,
- roleDescription: stringAttribute(element, kAXRoleDescriptionAttribute as String),
- title: stringAttribute(element, kAXTitleAttribute as String),
- label: stringAttribute(element, kAXDescriptionAttribute as String),
+ roleDescription: roleDescription,
+ title: title,
+ label: label,
linkText: linkText,
value: value,
placeholder: placeholder,
- url: stringAttribute(element, kAXURLAttribute as String),
+ url: url,
traits: [],
rawActions: rawActions,
childCount: children.count,
@@ -1553,19 +1558,19 @@ private final class TreeRenderer {
)
let name = SnapshotRenderHeuristics.displayName(baseNode)
let meaningful = SnapshotRenderHeuristics.meaningfulActions(rawActions, role: role)
- let localFrame = frame(element, windowBounds: windowBounds)
- let traits = traitsFor(element, role: role)
- let webAreaDepth = webAreaDepth(role: role, ancestors: ancestors)
- let summary = genericTextSummary(element, role: role, name: name, actions: meaningful, traits: traits)
+ let localFrame = reader.frame(element, windowBounds: windowBounds)
+ let traits = reader.traitsFor(element, role: role)
+ let webAreaDepth = reader.webAreaDepth(role: role, ancestors: ancestors)
+ let summary = reader.genericTextSummary(element, role: role, name: name, actions: meaningful, traits: traits)
let node = SnapshotRenderNode(
role: role,
- roleDescription: stringAttribute(element, kAXRoleDescriptionAttribute as String),
- title: stringAttribute(element, kAXTitleAttribute as String),
- label: stringAttribute(element, kAXDescriptionAttribute as String),
+ roleDescription: roleDescription,
+ title: title,
+ label: label,
linkText: linkText,
value: value,
placeholder: placeholder,
- url: stringAttribute(element, kAXURLAttribute as String),
+ url: url,
traits: traits,
rawActions: rawActions,
childCount: children.count,
@@ -1617,6 +1622,23 @@ private final class TreeRenderer {
}
}
+ private func tabStripCompaction(parent: SnapshotRenderNode, children: [AXUIElement]) -> SnapshotTabStripCompaction? {
+ let childNodes = children.map { child in
+ let role = reader.stringAttribute(child, kAXRoleAttribute as String) ?? "AXUnknown"
+ return SnapshotRenderNode(
+ role: role,
+ roleDescription: reader.stringAttribute(child, kAXRoleDescriptionAttribute as String),
+ title: reader.stringAttribute(child, kAXTitleAttribute as String),
+ label: reader.stringAttribute(child, kAXDescriptionAttribute as String),
+ value: reader.valueString(child, role: role),
+ traits: reader.traitsFor(child, role: role)
+ )
+ }
+ // Why: browsers expose every open tab through AX; retaining only the active
+ // tab keeps snapshots focused on the current page instead of stale tab titles.
+ return SnapshotRenderHeuristics.tabStripCompaction(parent: parent, children: childNodes)
+ }
+
private func compactRenderedBrowserTabs(parent: SnapshotRenderNode, startLine: Int, depth: Int) {
guard SnapshotRenderHeuristics.roleText(parent) == "scroll area" else { return }
let indent = String(repeating: "\t", count: depth)
@@ -1647,44 +1669,301 @@ private final class TreeRenderer {
}
}
-private func valueString(_ element: AXUIElement) -> String? {
- if isSecureTextElement(element) {
- return "[redacted]"
+private final class AXSnapshotReader {
+ private enum CachedAttribute {
+ case missing
+ case found(CFTypeRef)
}
- if let string = stringAttribute(element, kAXValueAttribute as String) {
- return string
+
+ private final class ElementCache {
+ let element: AXUIElement
+ var loadedAttributeNames = false
+ var advertisedAttributes: Set?
+ var attributes: [String: CachedAttribute] = [:]
+ var actions: [String]?
+ var settable: [String: Bool] = [:]
+
+ init(element: AXUIElement) {
+ self.element = element
+ }
}
- if let number = numberAttribute(element, kAXValueAttribute as String) {
- return number.stringValue
+
+ private var elementsByHash: [CFHashCode: [ElementCache]] = [:]
+
+ func stringAttribute(_ element: AXUIElement, _ attribute: String) -> String? {
+ guard let value = copyAttribute(element, attribute) else { return nil }
+ if CFGetTypeID(value) == CFStringGetTypeID(), let string = value as? String {
+ let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? nil : trimmed
+ }
+ if CFGetTypeID(value) == CFURLGetTypeID(), let url = value as? URL {
+ return url.absoluteString
+ }
+ return nil
}
- return nil
-}
-private func isSecureTextElement(_ element: AXUIElement) -> Bool {
- let role = stringAttribute(element, kAXRoleAttribute as String)?.lowercased() ?? ""
- let subrole = stringAttribute(element, kAXSubroleAttribute as String)?.lowercased() ?? ""
- let title = stringAttribute(element, kAXTitleAttribute as String)?.lowercased() ?? ""
- let description = stringAttribute(element, kAXDescriptionAttribute as String)?.lowercased() ?? ""
- let placeholder = placeholderString(element)?.lowercased() ?? ""
- let haystack = [role, subrole, title, description, placeholder].joined(separator: " ")
- return haystack.contains("secure") ||
- haystack.contains("password") ||
- haystack.contains("passcode") ||
- haystack.contains("verification code") ||
- haystack.contains("one-time code")
-}
+ func boolAttribute(_ element: AXUIElement, _ attribute: String) -> Bool? {
+ copyAttribute(element, attribute) as? Bool
+ }
-private func placeholderString(_ element: AXUIElement) -> String? {
- stringAttribute(element, "AXPlaceholderValue") ?? stringAttribute(element, "AXPlaceholder")
-}
+ func numberAttribute(_ element: AXUIElement, _ attribute: String) -> NSNumber? {
+ copyAttribute(element, attribute) as? NSNumber
+ }
-private func traitsFor(_ element: AXUIElement, role: String) -> [String] {
- var traits: [String] = []
- if boolAttribute(element, kAXSelectedAttribute as String) == true { traits.append("selected") }
- if boolAttribute(element, kAXExpandedAttribute as String) == true { traits.append("expanded") }
- if boolAttribute(element, kAXEnabledAttribute as String) == false { traits.append("disabled") }
- if valueSettableRoles.contains(role), isSettable(element, kAXValueAttribute as String) { traits.append("settable") }
- return traits
+ func copyArray(_ element: AXUIElement, _ attribute: String) -> [AXUIElement]? {
+ copyAttribute(element, attribute) as? [AXUIElement]
+ }
+
+ func actions(_ element: AXUIElement) -> [String] {
+ let cache = cache(for: element)
+ if let actions = cache.actions {
+ return actions
+ }
+ var value: CFArray?
+ let actions = AXUIElementCopyActionNames(element, &value) == .success ? value as? [String] ?? [] : []
+ cache.actions = actions
+ return actions
+ }
+
+ func isSettable(_ element: AXUIElement, _ attribute: String) -> Bool {
+ let cache = cache(for: element)
+ if let cached = cache.settable[attribute] {
+ return cached
+ }
+ var settable = DarwinBoolean(false)
+ let value = AXUIElementIsAttributeSettable(element, attribute as CFString, &settable) == .success && settable.boolValue
+ cache.settable[attribute] = value
+ return value
+ }
+
+ func frame(_ element: AXUIElement, windowBounds: CGRect) -> CGRect? {
+ guard let absolute = absoluteFrame(element) else { return nil }
+ return CGRect(
+ x: absolute.minX - windowBounds.minX,
+ y: absolute.minY - windowBounds.minY,
+ width: absolute.width,
+ height: absolute.height
+ )
+ }
+
+ func primaryChildren(_ element: AXUIElement, role: String, windowBounds: CGRect) -> [AXUIElement] {
+ if usesRowsAsPrimaryChildren(role: role), let rows = copyArray(element, kAXRowsAttribute as String), !rows.isEmpty {
+ return visibleRows(rows, parent: element, windowBounds: windowBounds)
+ }
+ return copyArray(element, kAXChildrenAttribute as String) ?? []
+ }
+
+ func valueString(_ element: AXUIElement, role: String) -> String? {
+ if isSecureTextElement(element, role: role) {
+ return "[redacted]"
+ }
+ if let string = stringAttribute(element, kAXValueAttribute as String) {
+ return string
+ }
+ if let number = numberAttribute(element, kAXValueAttribute as String) {
+ return number.stringValue
+ }
+ return nil
+ }
+
+ func placeholderString(_ element: AXUIElement) -> String? {
+ stringAttribute(element, "AXPlaceholderValue") ?? stringAttribute(element, "AXPlaceholder")
+ }
+
+ func traitsFor(_ element: AXUIElement, role: String) -> [String] {
+ var traits: [String] = []
+ if boolAttribute(element, kAXSelectedAttribute as String) == true { traits.append("selected") }
+ if boolAttribute(element, kAXExpandedAttribute as String) == true { traits.append("expanded") }
+ if boolAttribute(element, kAXEnabledAttribute as String) == false { traits.append("disabled") }
+ if valueSettableRoles.contains(role), isSettable(element, kAXValueAttribute as String) { traits.append("settable") }
+ return traits
+ }
+
+ func genericTextSummary(
+ _ element: AXUIElement,
+ role: String,
+ name: String?,
+ actions: [String],
+ traits: [String]
+ ) -> String? {
+ guard (role == kAXGroupRole as String || role == kAXUnknownRole as String),
+ name == nil,
+ actions.isEmpty,
+ traits.isEmpty,
+ isPlainTextSubtree(element, maxDepth: 4)
+ else {
+ return nil
+ }
+ let texts = descendantTextSnippets(element, limit: 8, maxDepth: 4)
+ guard texts.count >= 2 else { return nil }
+ let summary = texts.joined(separator: " ")
+ guard summary.count <= 220 else { return nil }
+ return summary
+ }
+
+ func rowTextSummary(_ element: AXUIElement, role: String) -> String? {
+ guard ["AXRow", "AXCell", "AXOutlineRow"].contains(role) else { return nil }
+ let texts = descendantTextSnippets(element, limit: 6, maxDepth: 3)
+ guard !texts.isEmpty else { return nil }
+ return texts.joined(separator: " ")
+ }
+
+ func descendantTextSnippets(_ element: AXUIElement, limit: Int, maxDepth: Int) -> [String] {
+ var values: [String] = []
+ var seen = Set()
+
+ func collect(_ node: AXUIElement, depth: Int) {
+ guard values.count < limit, depth <= maxDepth else { return }
+ let role = stringAttribute(node, kAXRoleAttribute as String) ?? ""
+ if role == kAXStaticTextRole as String || role == "AXLink" {
+ for candidate in [
+ stringAttribute(node, kAXValueAttribute as String),
+ stringAttribute(node, kAXTitleAttribute as String),
+ stringAttribute(node, kAXDescriptionAttribute as String),
+ ] {
+ guard let candidate else { continue }
+ let text = preview(candidate, maxLength: 80)
+ guard !text.isEmpty, seen.insert(text).inserted else { continue }
+ values.append(text)
+ if values.count >= limit { return }
+ }
+ }
+ for child in copyArray(node, kAXChildrenAttribute as String) ?? [] {
+ collect(child, depth: depth + 1)
+ if values.count >= limit { return }
+ }
+ }
+
+ collect(element, depth: 0)
+ return values
+ }
+
+ func webAreaDepth(role: String, ancestors: [AXUIElement]) -> Int? {
+ if role == "AXWebArea" { return 0 }
+ guard let index = ancestors.firstIndex(where: { stringAttribute($0, kAXRoleAttribute as String) == "AXWebArea" }) else {
+ return nil
+ }
+ return ancestors.count - index
+ }
+
+ private func copyAttribute(_ element: AXUIElement, _ attribute: String) -> CFTypeRef? {
+ let cache = cache(for: element)
+ if let cached = cache.attributes[attribute] {
+ switch cached {
+ case .missing:
+ return nil
+ case let .found(value):
+ return value
+ }
+ }
+ if let advertisedAttributes = advertisedAttributes(cache),
+ !SnapshotRenderHeuristics.supportsAttribute(attribute, advertisedAttributes: advertisedAttributes) {
+ cache.attributes[attribute] = .missing
+ return nil
+ }
+ var value: CFTypeRef?
+ guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success, let value else {
+ cache.attributes[attribute] = .missing
+ return nil
+ }
+ cache.attributes[attribute] = .found(value)
+ return value
+ }
+
+ private func advertisedAttributes(_ cache: ElementCache) -> Set? {
+ if cache.loadedAttributeNames {
+ return cache.advertisedAttributes
+ }
+ cache.loadedAttributeNames = true
+ var value: CFArray?
+ guard AXUIElementCopyAttributeNames(cache.element, &value) == .success, let attributes = value as? [String] else {
+ return nil
+ }
+ cache.advertisedAttributes = Set(attributes)
+ return cache.advertisedAttributes
+ }
+
+ private func absoluteFrame(_ element: AXUIElement) -> CGRect? {
+ guard let positionValue = copyAttribute(element, kAXPositionAttribute as String),
+ let sizeValue = copyAttribute(element, kAXSizeAttribute as String)
+ else {
+ return nil
+ }
+ var point = CGPoint.zero
+ var size = CGSize.zero
+ guard AXValueGetValue(positionValue as! AXValue, .cgPoint, &point),
+ AXValueGetValue(sizeValue as! AXValue, .cgSize, &size)
+ else {
+ return nil
+ }
+ return CGRect(origin: point, size: size)
+ }
+
+ private func visibleRows(_ rows: [AXUIElement], parent: AXUIElement, windowBounds: CGRect) -> [AXUIElement] {
+ guard let parentFrame = frame(parent, windowBounds: windowBounds) else {
+ return Array(rows.prefix(20))
+ }
+ let visible = rows.filter { row in
+ guard let rowFrame = frame(row, windowBounds: windowBounds) else { return false }
+ return rowFrame.intersects(parentFrame)
+ }
+ return Array((visible.isEmpty ? rows : visible).prefix(20))
+ }
+
+ private func isSecureTextElement(_ element: AXUIElement, role: String) -> Bool {
+ guard SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: role) else {
+ return false
+ }
+ let haystack = [
+ role,
+ stringAttribute(element, kAXSubroleAttribute as String) ?? "",
+ stringAttribute(element, kAXTitleAttribute as String) ?? "",
+ stringAttribute(element, kAXDescriptionAttribute as String) ?? "",
+ placeholderString(element) ?? "",
+ ].joined(separator: " ").lowercased()
+ return haystack.contains("secure") ||
+ haystack.contains("password") ||
+ haystack.contains("passcode") ||
+ haystack.contains("verification code") ||
+ haystack.contains("one-time code")
+ }
+
+ private func isPlainTextSubtree(_ element: AXUIElement, maxDepth: Int) -> Bool {
+ var sawText = false
+ let allowedContainerRoles: Set = [
+ kAXGroupRole as String,
+ kAXUnknownRole as String,
+ kAXStaticTextRole as String,
+ "AXLink",
+ "AXImage",
+ ]
+
+ func visit(_ node: AXUIElement, depth: Int) -> Bool {
+ guard depth <= maxDepth else { return false }
+ let role = stringAttribute(node, kAXRoleAttribute as String) ?? "AXUnknown"
+ guard allowedContainerRoles.contains(role) else { return false }
+ if role == kAXStaticTextRole as String || role == "AXLink" {
+ sawText = true
+ }
+ guard SnapshotRenderHeuristics.meaningfulActions(actions(node), role: role).isEmpty else { return false }
+ for child in copyArray(node, kAXChildrenAttribute as String) ?? [] {
+ guard visit(child, depth: depth + 1) else { return false }
+ }
+ return true
+ }
+
+ return visit(element, depth: 0) && sawText
+ }
+
+ private func cache(for element: AXUIElement) -> ElementCache {
+ let hash = CFHash(element)
+ if let cache = elementsByHash[hash]?.first(where: { CFEqual($0.element, element) }) {
+ return cache
+ }
+ let cache = ElementCache(element: element)
+ elementsByHash[hash, default: []].append(cache)
+ return cache
+ }
}
private let valueSettableRoles: Set = [
@@ -1697,24 +1976,6 @@ private let valueSettableRoles: Set = [
kAXTextFieldRole as String,
]
-private func primaryChildren(_ element: AXUIElement, role: String, windowBounds: CGRect) -> [AXUIElement] {
- if usesRowsAsPrimaryChildren(role: role), let rows = copyArray(element, kAXRowsAttribute as String), !rows.isEmpty {
- return visibleRows(rows, parent: element, windowBounds: windowBounds)
- }
- return copyArray(element, kAXChildrenAttribute as String) ?? []
-}
-
-private func visibleRows(_ rows: [AXUIElement], parent: AXUIElement, windowBounds: CGRect) -> [AXUIElement] {
- guard let parentFrame = frame(parent, windowBounds: windowBounds) else {
- return Array(rows.prefix(20))
- }
- let visible = rows.filter { row in
- guard let rowFrame = frame(row, windowBounds: windowBounds) else { return false }
- return rowFrame.intersects(parentFrame)
- }
- return Array((visible.isEmpty ? rows : visible).prefix(20))
-}
-
private func usesRowsAsPrimaryChildren(role: String) -> Bool {
[
kAXBrowserRole as String,
@@ -1724,23 +1985,6 @@ private func usesRowsAsPrimaryChildren(role: String) -> Bool {
].contains(role)
}
-private func tabStripCompaction(parent: SnapshotRenderNode, children: [AXUIElement]) -> SnapshotTabStripCompaction? {
- let childNodes = children.map { child in
- let role = stringAttribute(child, kAXRoleAttribute as String) ?? "AXUnknown"
- return SnapshotRenderNode(
- role: role,
- roleDescription: stringAttribute(child, kAXRoleDescriptionAttribute as String),
- title: stringAttribute(child, kAXTitleAttribute as String),
- label: stringAttribute(child, kAXDescriptionAttribute as String),
- value: valueString(child),
- traits: traitsFor(child, role: role)
- )
- }
- // Why: browsers expose every open tab through AX; retaining only the active
- // tab keeps snapshots focused on the current page instead of stale tab titles.
- return SnapshotRenderHeuristics.tabStripCompaction(parent: parent, children: childNodes)
-}
-
private func isDirectRenderedBrowserTabLine(_ line: String, indent: String) -> Bool {
guard line.hasPrefix(indent), !line.dropFirst(indent.count).hasPrefix("\t") else {
return false
@@ -1761,100 +2005,6 @@ private func renderedElementIndex(_ line: String, indent: String) -> Int? {
return Int(digits)
}
-private func genericTextSummary(
- _ element: AXUIElement,
- role: String,
- name: String?,
- actions: [String],
- traits: [String]
-) -> String? {
- guard (role == kAXGroupRole as String || role == kAXUnknownRole as String),
- name == nil,
- actions.isEmpty,
- traits.isEmpty,
- isPlainTextSubtree(element, maxDepth: 4)
- else {
- return nil
- }
- let texts = descendantTextSnippets(element, limit: 8, maxDepth: 4)
- guard texts.count >= 2 else { return nil }
- let summary = texts.joined(separator: " ")
- guard summary.count <= 220 else { return nil }
- return summary
-}
-
-private func rowTextSummary(_ element: AXUIElement, role: String) -> String? {
- guard ["AXRow", "AXCell", "AXOutlineRow"].contains(role) else { return nil }
- let texts = descendantTextSnippets(element, limit: 6, maxDepth: 3)
- guard !texts.isEmpty else { return nil }
- return texts.joined(separator: " ")
-}
-
-private func isPlainTextSubtree(_ element: AXUIElement, maxDepth: Int) -> Bool {
- var sawText = false
- let allowedContainerRoles: Set = [
- kAXGroupRole as String,
- kAXUnknownRole as String,
- kAXStaticTextRole as String,
- "AXLink",
- "AXImage",
- ]
-
- func visit(_ node: AXUIElement, depth: Int) -> Bool {
- guard depth <= maxDepth else { return false }
- let role = stringAttribute(node, kAXRoleAttribute as String) ?? "AXUnknown"
- guard allowedContainerRoles.contains(role) else { return false }
- if role == kAXStaticTextRole as String || role == "AXLink" {
- sawText = true
- }
- guard SnapshotRenderHeuristics.meaningfulActions(actions(node), role: role).isEmpty else { return false }
- for child in copyArray(node, kAXChildrenAttribute as String) ?? [] {
- guard visit(child, depth: depth + 1) else { return false }
- }
- return true
- }
-
- return visit(element, depth: 0) && sawText
-}
-
-private func descendantTextSnippets(_ element: AXUIElement, limit: Int, maxDepth: Int) -> [String] {
- var values: [String] = []
- var seen = Set()
-
- func collect(_ node: AXUIElement, depth: Int) {
- guard values.count < limit, depth <= maxDepth else { return }
- let role = stringAttribute(node, kAXRoleAttribute as String) ?? ""
- if role == kAXStaticTextRole as String || role == "AXLink" {
- for candidate in [
- stringAttribute(node, kAXValueAttribute as String),
- stringAttribute(node, kAXTitleAttribute as String),
- stringAttribute(node, kAXDescriptionAttribute as String),
- ] {
- guard let candidate else { continue }
- let text = preview(candidate, maxLength: 80)
- guard !text.isEmpty, seen.insert(text).inserted else { continue }
- values.append(text)
- if values.count >= limit { return }
- }
- }
- for child in copyArray(node, kAXChildrenAttribute as String) ?? [] {
- collect(child, depth: depth + 1)
- if values.count >= limit { return }
- }
- }
-
- collect(element, depth: 0)
- return values
-}
-
-private func webAreaDepth(role: String, ancestors: [AXUIElement]) -> Int? {
- if role == "AXWebArea" { return 0 }
- guard let index = ancestors.firstIndex(where: { stringAttribute($0, kAXRoleAttribute as String) == "AXWebArea" }) else {
- return nil
- }
- return ancestors.count - index
-}
-
private func sanitize(_ value: String) -> String {
value.replacingOccurrences(of: "\n", with: " ").replacingOccurrences(of: "\r", with: " ")
}
diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SnapshotRendering.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SnapshotRendering.swift
index 275738028cd..53205adf5d3 100644
--- a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SnapshotRendering.swift
+++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SnapshotRendering.swift
@@ -60,6 +60,20 @@ public struct SnapshotTabStripCompaction: Equatable {
}
public enum SnapshotRenderHeuristics {
+ public static func shouldProbeSecureTextMetadata(role: String) -> Bool {
+ let normalized = role.lowercased()
+ return normalized.contains("text") ||
+ normalized.contains("field") ||
+ normalized.contains("password") ||
+ normalized.contains("search") ||
+ normalized.contains("combo")
+ }
+
+ public static func supportsAttribute(_ attribute: String, advertisedAttributes: Set?) -> Bool {
+ guard let advertisedAttributes else { return true }
+ return advertisedAttributes.contains(attribute)
+ }
+
public static func displayName(_ node: SnapshotRenderNode) -> String? {
if let title = clean(node.title) {
return title
diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SnapshotRenderingTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SnapshotRenderingTests.swift
index b85e70cc801..dce1bdc304c 100644
--- a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SnapshotRenderingTests.swift
+++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SnapshotRenderingTests.swift
@@ -2,6 +2,24 @@ import XCTest
@testable import OrcaComputerUseMacOSCore
final class SnapshotRenderingTests: XCTestCase {
+ func testSkipsUnsupportedAdvertisedAttributes() {
+ let advertised: Set = ["AXRole", "AXChildren"]
+
+ XCTAssertTrue(SnapshotRenderHeuristics.supportsAttribute("AXRole", advertisedAttributes: advertised))
+ XCTAssertFalse(SnapshotRenderHeuristics.supportsAttribute("AXTitle", advertisedAttributes: advertised))
+ }
+
+ func testUnknownAttributeAdvertisementsStayPermissive() {
+ XCTAssertTrue(SnapshotRenderHeuristics.supportsAttribute("AXTitle", advertisedAttributes: nil))
+ }
+
+ func testSecureTextMetadataOnlyProbesTextLikeRoles() {
+ XCTAssertTrue(SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: "AXTextField"))
+ XCTAssertTrue(SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: "AXSearchField"))
+ XCTAssertFalse(SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: "AXGroup"))
+ XCTAssertFalse(SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: "AXButton"))
+ }
+
func testElidesAnonymousWrappers() {
let node = SnapshotRenderNode(role: "AXGroup", childCount: 1)
diff --git a/package.json b/package.json
index 35762ce1269..6b08ed1e086 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "orca",
- "version": "1.4.92-rc.0",
+ "version": "1.4.94",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -70,6 +70,7 @@
"build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac",
"build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux AppImage deb",
"test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headless",
+ "test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:terminal-rendering-release-evidence": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts tests/e2e/terminal-long-table-scroll-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2",
"test:e2e:terminal-perf": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-typing-latency.spec.ts tests/e2e/terminal-foreground-redraw-freeze.spec.ts tests/e2e/terminal-output-scheduler.spec.ts tests/e2e/terminal-hidden-tui-visual-restore.spec.ts tests/e2e/artificial-opencode-terminal-load.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2",
@@ -78,9 +79,13 @@
"test:e2e:terminal-perf:check-report": "node config/scripts/check-terminal-perf-report-budgets.mjs",
"test:e2e:terminal-perf:summarize": "node config/scripts/summarize-terminal-perf-report.mjs",
"test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs",
+ "test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs",
"test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful",
"test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts",
- "bench:idle-cpu": "pnpm run ensure:electron-runtime && node config/scripts/run-idle-cpu-benchmark.mjs"
+ "bench:idle-cpu": "pnpm run ensure:electron-runtime && node config/scripts/run-idle-cpu-benchmark.mjs",
+ "bench:startup": "pnpm run ensure:electron-runtime && node tools/benchmarks/startup-time-bench.mjs",
+ "bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tools/benchmarks/daemon-coldstart-bench.mjs",
+ "bench:compare": "node config/scripts/compare-benchmark-artifacts.mjs"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
diff --git a/resources/linux/packaging/after-install.sh b/resources/linux/packaging/after-install.sh
index 393bfb6a89c..a5b598e2bf2 100755
--- a/resources/linux/packaging/after-install.sh
+++ b/resources/linux/packaging/after-install.sh
@@ -12,6 +12,13 @@ set -e
link="/usr/bin/orca-ide"
for dir in /opt/Orca /opt/orca-ide /opt/orca; do
+ sandbox="$dir/chrome-sandbox"
+ if [ -f "$sandbox" ]; then
+ # Why: packaged Linux installs must leave Chromium's sandbox helper usable
+ # on hosts where unprivileged user namespaces are unavailable.
+ chmod 4755 "$sandbox" || true
+ fi
+
shim="$dir/resources/bin/orca-ide"
if [ -x "$shim" ]; then
# Only manage our own symlink; never clobber an unrelated /usr/bin/orca-ide.
diff --git a/skills/orca-cli/SKILL.md b/skills/orca-cli/SKILL.md
index b36b2fd2109..8370ab87ca4 100644
--- a/skills/orca-cli/SKILL.md
+++ b/skills/orca-cli/SKILL.md
@@ -4,7 +4,7 @@ description: >-
Use the public `orca` CLI to operate Orca-managed worktrees, folder contexts,
terminals, repos, automations, worktree comments, and the browser embedded
inside the Orca app. Use when the user says "$orca-cli", "use orca cli",
- "Orca worktree", "child worktree", "spawn codex/claude in a worktree",
+ "Orca worktree", "child worktree", "cardStatus", "spawn codex/claude in a worktree",
"read/wait/send Orca terminal", "terminal send", "Orca browser", or "control
the browser inside Orca". Prefer this over raw `git worktree`, ad hoc PTYs,
Playwright, or Computer Use when the task touches Orca-managed state. Use
@@ -61,6 +61,7 @@ orca worktree create --name child-task --agent codex --prompt "hi" --json
orca worktree create --name independent-task --no-parent --json
orca worktree set --worktree id: --display-name "My Task" --json
orca worktree set --worktree active --comment "reproduced bug; testing fix" --json
+orca worktree set --worktree active --workspace-status in-review --json
orca worktree rm --worktree id: --force --json
```
@@ -108,6 +109,8 @@ orca worktree set --worktree active --comment "fix implemented; running integrat
Update after meaningful state changes such as repro, fix, validation, handoff, or blocker. Keep comments short/current; failures are best-effort unless Orca state was requested.
+Card status uses `--workspace-status `; defaults are `todo`, `in-progress`, `in-review`, `completed`.
+
## Terminals
Common commands:
diff --git a/src/cli/handlers/agent-hooks.test.ts b/src/cli/handlers/agent-hooks.test.ts
index 57c79285712..5b0fc73648f 100644
--- a/src/cli/handlers/agent-hooks.test.ts
+++ b/src/cli/handlers/agent-hooks.test.ts
@@ -14,7 +14,18 @@ const {
} = vi.hoisted(() => ({
applyAgentStatusHooksEnabledMock: vi.fn(),
callMock: vi.fn(),
- getCliStatusMock: vi.fn(() => Promise.resolve({ result: { runtime: { reachable: false } } })),
+ getCliStatusMock: vi.fn(() =>
+ Promise.resolve({
+ id: 'test-status',
+ ok: true,
+ result: {
+ app: { running: false, pid: null },
+ runtime: { state: 'not_running', reachable: false, runtimeId: null },
+ graph: { state: 'not_running' }
+ },
+ _meta: { runtimeId: 'test' }
+ })
+ ),
getDefaultUserDataPathMock: vi.fn(),
getManagedAgentHookStatusesMock: vi.fn()
}))
diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts
index f7ca2d3ea40..43c08542c60 100644
--- a/src/main/agent-hooks/installer-utils.test.ts
+++ b/src/main/agent-hooks/installer-utils.test.ts
@@ -20,6 +20,7 @@ import {
hookDefinitionHasManagedCommand,
removeManagedCommands,
wrapPosixHookCommand,
+ wrapWindowsHookCommand,
writeManagedScript,
writeHooksJson,
type HooksConfig
@@ -167,6 +168,11 @@ describe('createManagedCommandMatcher', () => {
).toBe(true)
})
+ it('matches encoded Windows launcher commands by decoding their script path', () => {
+ const command = wrapWindowsHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd')
+ expect(match(command)).toBe(true)
+ })
+
it('matches the legacy per-userData script path AND the new shared ~/.orca path', () => {
// Why: install() must sweep old per-userData commands when migrating to
// the shared ~/.orca script path, or stale launchers keep failing.
@@ -332,6 +338,57 @@ describe('wrapPosixHookCommand', () => {
)
})
+describe('wrapWindowsHookCommand', () => {
+ function decodeWindowsHookCommand(command: string): string {
+ const encodedCommand = command.match(/ -EncodedCommand (\S+)$/)?.[1]
+ expect(encodedCommand).toBeTruthy()
+ return Buffer.from(encodedCommand!, 'base64').toString('utf16le')
+ }
+
+ it('invokes the .cmd through an encoded PowerShell command', () => {
+ const command = wrapWindowsHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd')
+ expect(command).toMatch(/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/)
+ expect(decodeWindowsHookCommand(command)).toBe(
+ "& 'C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd'; exit $LASTEXITCODE"
+ )
+ })
+
+ // Why: a user profile path like `C:\Users\Jane Doe` is the regression from
+ // #6078 — the raw path used to be split at the space. The wrapper must keep
+ // the whole path inside the encoded command so shells do not split it.
+ it('preserves spaces in the script path (user profile with space case)', () => {
+ const cmd = wrapWindowsHookCommand('C:\\Users\\Jorge Silva\\.orca\\agent-hooks\\codex-hook.cmd')
+ expect(decodeWindowsHookCommand(cmd)).toBe(
+ "& 'C:\\Users\\Jorge Silva\\.orca\\agent-hooks\\codex-hook.cmd'; exit $LASTEXITCODE"
+ )
+ })
+
+ it('keeps cmd.exe percent expansion and caret escapes out of the command line', () => {
+ const cmd = wrapWindowsHookCommand('C:\\Users\\%ORCA_TEST%\\a^b\\codex-hook.cmd')
+ expect(cmd).not.toContain('%ORCA_TEST%')
+ expect(cmd).not.toContain('^')
+ expect(decodeWindowsHookCommand(cmd)).toBe(
+ "& 'C:\\Users\\%ORCA_TEST%\\a^b\\codex-hook.cmd'; exit $LASTEXITCODE"
+ )
+ })
+
+ it.skipIf(process.platform !== 'win32')(
+ 'executes a script path containing a cmd.exe caret literally',
+ () => {
+ const scriptDir = join(tmpDir, 'home with ^ caret', '.orca', 'agent-hooks')
+ mkdirSync(scriptDir, { recursive: true })
+ const scriptPath = join(scriptDir, 'codex-hook.cmd')
+ writeFileSync(scriptPath, '@echo off\r\nexit /b 7\r\n', 'utf-8')
+
+ const result = spawnSync('cmd.exe', ['/d', '/c', wrapWindowsHookCommand(scriptPath)], {
+ env: { ...process.env, ORCA_WRAP_TEST: 'expanded' }
+ })
+
+ expect(result.status).toBe(7)
+ }
+ )
+})
+
describe('buildWindowsAgentHookPostCommand', () => {
it('forces UTF-8 for redirected hook stdin and POST bodies', () => {
const command = buildWindowsAgentHookPostCommand('codex')
diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts
index 07f8e834d9d..a533fb0ed1a 100644
--- a/src/main/agent-hooks/installer-utils.ts
+++ b/src/main/agent-hooks/installer-utils.ts
@@ -75,11 +75,25 @@ export function createManagedCommandMatcher(
if (!command) {
return false
}
- const normalizedCommand = command.replaceAll('\\', '/')
+ const decodedCommand = decodePowerShellEncodedCommand(command)
+ const searchText = decodedCommand ? `${command}\n${decodedCommand}` : command
+ const normalizedCommand = searchText.replaceAll('\\', '/')
return needles.some((needle) => normalizedCommand.includes(needle))
}
}
+function decodePowerShellEncodedCommand(command: string): string | null {
+ const match = command.match(/\s-EncodedCommand\s+(\S+)/i)
+ if (!match) {
+ return null
+ }
+ try {
+ return Buffer.from(match[1], 'base64').toString('utf16le')
+ } catch {
+ return null
+ }
+}
+
// Why: prod, dev, and parallel Orca instances must write the same managed
// settings entry instead of racing between per-userData script paths.
export function getSharedManagedScriptPath(scriptFileName: string): string {
@@ -106,6 +120,21 @@ export function wrapPosixHookCommand(scriptPath: string, env: Record# candidate path. Legacy file-based sessions use
+ // real filesystem paths and fall through to the JSON parser.
+ const sqliteCandidate = splitOpenCodeSqliteCandidate(candidate.file.path)
+ if (sqliteCandidate) {
+ return parseOpenCodeSqliteSession({
+ dbPath: sqliteCandidate.dbPath,
+ sessionId: sqliteCandidate.sessionId,
+ platform
+ })
+ }
return parseOpenCodeSessionFile(candidate.file, platform)
+ }
case 'grok':
return parseGrokSessionFile(candidate.file, platform)
case 'hermes':
diff --git a/src/main/ai-vault/session-scanner-codex-parser.test.ts b/src/main/ai-vault/session-scanner-codex-parser.test.ts
new file mode 100644
index 00000000000..19581fc8761
--- /dev/null
+++ b/src/main/ai-vault/session-scanner-codex-parser.test.ts
@@ -0,0 +1,88 @@
+import { mkdir, mkdtemp, rm, stat, writeFile } from 'fs/promises'
+import { tmpdir } from 'os'
+import { dirname, join } from 'path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { parseCodexSessionFile } from './session-scanner-codex-parser'
+
+let tempRoots: string[] = []
+
+afterEach(async () => {
+ await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
+ tempRoots = []
+})
+
+function jsonLines(records: unknown[]): string {
+ return records.map((record) => JSON.stringify(record)).join('\n')
+}
+
+describe('parseCodexSessionFile', () => {
+ it('does not double-count usage when token count formats switch', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-token-switch-'))
+ tempRoots.push(root)
+ const sessionPath = join(root, 'sessions', '2026', '06', '18', 'rollout-token-switch.jsonl')
+ await mkdir(dirname(sessionPath), { recursive: true })
+
+ await writeFile(
+ sessionPath,
+ jsonLines([
+ {
+ timestamp: '2026-06-18T10:00:00.000Z',
+ type: 'session_meta',
+ payload: { id: 'token-format-switch', cwd: '/repo/app' }
+ },
+ {
+ timestamp: '2026-06-18T10:00:01.000Z',
+ type: 'response_item',
+ payload: {
+ type: 'message',
+ role: 'user',
+ content: [{ type: 'text', text: 'Measure Codex token totals' }]
+ }
+ },
+ {
+ timestamp: '2026-06-18T10:00:02.000Z',
+ type: 'event_msg',
+ payload: {
+ type: 'token_count',
+ info: {
+ last_token_usage: {
+ input_tokens: 70,
+ cached_input_tokens: 20,
+ output_tokens: 30,
+ total_tokens: 100
+ }
+ }
+ }
+ },
+ {
+ timestamp: '2026-06-18T10:00:03.000Z',
+ type: 'event_msg',
+ payload: {
+ type: 'token_count',
+ info: {
+ total_token_usage: {
+ input_tokens: 90,
+ cached_input_tokens: 25,
+ output_tokens: 60,
+ total_tokens: 150
+ }
+ }
+ }
+ }
+ ])
+ )
+
+ const sessionStat = await stat(sessionPath)
+ const session = await parseCodexSessionFile(
+ {
+ path: sessionPath,
+ mtimeMs: sessionStat.mtimeMs,
+ modifiedAt: sessionStat.mtime.toISOString()
+ },
+ 'darwin',
+ root
+ )
+
+ expect(session?.totalTokens).toBe(150)
+ })
+})
diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts
new file mode 100644
index 00000000000..5435b2eaf1c
--- /dev/null
+++ b/src/main/ai-vault/session-scanner-codex-parser.ts
@@ -0,0 +1,273 @@
+import { createReadStream } from 'fs'
+import { stat } from 'fs/promises'
+import { basename, dirname, join } from 'path'
+import { createInterface } from 'readline'
+import type { AiVaultSession } from '../../shared/ai-vault-types'
+import {
+ addPreviewContent,
+ createAccumulator,
+ finalizeSession,
+ sessionIdFromFileName,
+ updateTimeline
+} from './session-scanner-accumulator'
+import type { CodexUsageSnapshot, FileWithMtime } from './session-scanner-types'
+import {
+ asRecord,
+ extractContentText,
+ extractGitBranch,
+ extractModel,
+ extractString,
+ normalizeCodexUsage,
+ normalizeTitleText,
+ parseJsonObject,
+ subtractCodexUsage
+} from './session-scanner-values'
+
+const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl'
+
+type CodexSessionIndexTitleCacheEntry = {
+ signature: string
+ titles: Map
+}
+
+const codexSessionIndexTitleCache = new Map>()
+
+export async function parseCodexSessionFile(
+ file: FileWithMtime,
+ platform: NodeJS.Platform = process.platform,
+ codexHome: string | null = null
+): Promise {
+ const accumulator = createAccumulator({
+ agent: 'codex',
+ file,
+ sessionId: sessionIdFromFileName(file.path)
+ })
+ let previousTotals: CodexUsageSnapshot | null = null
+
+ const lines = createInterface({
+ input: createReadStream(file.path, { encoding: 'utf-8' }),
+ crlfDelay: Infinity
+ })
+
+ for await (const line of lines) {
+ const record = parseJsonObject(line)
+ if (!record) {
+ continue
+ }
+
+ updateTimeline(accumulator, extractString(record.timestamp))
+
+ const payload = asRecord(record.payload)
+ if (record.type === 'session_meta' && payload) {
+ if (isCodexWorkerSession(payload)) {
+ // Why: Codex writes internal worker/sub-agent transcripts into the same
+ // history tree; AI Vault should show user-started sessions only.
+ return null
+ }
+ const sessionId = extractString(payload.id)
+ if (sessionId) {
+ accumulator.sessionId = sessionId
+ }
+ const indexedTitle =
+ extractCodexSessionMetadataTitle(payload) ??
+ (await readCodexSessionIndexTitle(file.path, codexHome, accumulator.sessionId))
+ if (indexedTitle) {
+ accumulator.title = indexedTitle
+ }
+ const cwd = extractString(payload.cwd)
+ if (cwd) {
+ accumulator.cwd = cwd
+ }
+ accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch
+ continue
+ }
+
+ if (record.type === 'turn_context' && payload) {
+ const cwd = extractString(payload.cwd)
+ if (cwd) {
+ accumulator.cwd = cwd
+ }
+ const model = extractModel(payload)
+ if (model) {
+ accumulator.model = model
+ }
+ continue
+ }
+
+ if (!payload) {
+ continue
+ }
+
+ if (record.type === 'response_item' && payload.type === 'message') {
+ accumulator.messageCount++
+ if (payload.role === 'user' && !accumulator.title) {
+ accumulator.title = extractContentText(payload.content)
+ }
+ addPreviewContent(
+ accumulator,
+ payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown',
+ payload.content,
+ record.timestamp
+ )
+ continue
+ }
+
+ if (record.type !== 'event_msg') {
+ continue
+ }
+
+ if (payload.type === 'user_message') {
+ accumulator.messageCount++
+ if (!accumulator.title) {
+ accumulator.title = extractContentText(payload.message)
+ }
+ addPreviewContent(accumulator, 'user', payload.message, record.timestamp)
+ continue
+ }
+
+ if (payload.type === 'agent_message') {
+ accumulator.messageCount++
+ addPreviewContent(accumulator, 'assistant', payload.message, record.timestamp)
+ continue
+ }
+
+ if (payload.type !== 'token_count') {
+ continue
+ }
+
+ const info = asRecord(payload.info)
+ if (!info) {
+ continue
+ }
+ const totalUsage = normalizeCodexUsage(info.total_token_usage)
+ const lastUsage = normalizeCodexUsage(info.last_token_usage)
+ let delta: CodexUsageSnapshot | null = null
+ if (totalUsage) {
+ delta = subtractCodexUsage(totalUsage, previousTotals)
+ previousTotals = totalUsage
+ } else if (lastUsage) {
+ delta = lastUsage
+ previousTotals = previousTotals ? addCodexUsage(previousTotals, lastUsage) : lastUsage
+ }
+ if (delta) {
+ accumulator.totalTokens += delta.totalTokens
+ }
+ const model = extractModel(payload)
+ if (model) {
+ accumulator.model = model
+ }
+ }
+
+ return finalizeSession(accumulator, platform, { codexHome })
+}
+
+function addCodexUsage(
+ base: CodexUsageSnapshot,
+ increment: CodexUsageSnapshot
+): CodexUsageSnapshot {
+ return {
+ inputTokens: base.inputTokens + increment.inputTokens,
+ cachedInputTokens: base.cachedInputTokens + increment.cachedInputTokens,
+ outputTokens: base.outputTokens + increment.outputTokens,
+ reasoningOutputTokens: base.reasoningOutputTokens + increment.reasoningOutputTokens,
+ totalTokens: base.totalTokens + increment.totalTokens
+ }
+}
+
+function extractCodexThreadSource(payload: Record): string | null {
+ return extractString(payload.thread_source) ?? extractString(payload.threadSource)
+}
+
+function isCodexWorkerSession(payload: Record): boolean {
+ const threadSource = extractCodexThreadSource(payload)
+ if (threadSource) {
+ return threadSource.toLowerCase() !== 'user'
+ }
+
+ const source = asRecord(payload.source)
+ return Boolean(asRecord(source?.subagent))
+}
+
+function extractCodexSessionMetadataTitle(payload: Record): string | null {
+ return (
+ normalizeTitleText(extractString(payload.title) ?? '') ??
+ normalizeTitleText(extractString(payload.thread_name) ?? '') ??
+ normalizeTitleText(extractString(payload.threadName) ?? '')
+ )
+}
+
+async function readCodexSessionIndexTitle(
+ sessionFilePath: string,
+ codexHome: string | null,
+ sessionId: string
+): Promise {
+ const resolvedCodexHome = codexHome ?? codexHomeFromSessionFilePath(sessionFilePath)
+ if (!resolvedCodexHome) {
+ return null
+ }
+ const titleBySessionId = await readCodexSessionIndexTitles(resolvedCodexHome)
+ return titleBySessionId.get(sessionId) ?? null
+}
+
+function codexHomeFromSessionFilePath(sessionFilePath: string): string | null {
+ let currentDir = dirname(sessionFilePath)
+ while (currentDir && dirname(currentDir) !== currentDir) {
+ if (basename(currentDir) === 'sessions') {
+ return dirname(currentDir)
+ }
+ currentDir = dirname(currentDir)
+ }
+ return null
+}
+
+async function readCodexSessionIndexTitles(codexHome: string): Promise |