Merge remote-tracking branch 'origin/main' into brennanb2025/skill-update-detection

# Conflicts:
#	src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts
This commit is contained in:
brennanb2025
2026-06-23 13:55:06 -07:00
323 changed files with 22724 additions and 2177 deletions
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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)
</td>
<td width="50%">
@@ -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)
---
@@ -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 <path> --candidate <path> [--title <title>] [--output <path>] [--json-output <path>] [--higher-is-better <metric-key> ...]'
)
})
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<label>', path: 'base|path', kind: 'summary' },
candidate: { label: 'candidate', path: 'candidate.md', kind: 'summary' },
metrics: [
{
key: 'summary.value|with-pipe',
unit: '',
baseline: 1,
candidate: 2,
absoluteDelta: 1,
percentDelta: 100,
status: 'regressed'
}
],
skippedMetrics: [{ key: 'missing\nmetric', reason: 'missing | candidate' }]
})
expect(markdown).toContain('# Compare \\[Injected\\]\\(https://example\\.test\\)')
expect(markdown).toContain('Baseline: base \\<label\\> (base|path)')
expect(markdown).toContain(
'| summary\\.value\\|with\\-pipe | 1.0 | 2.0 | 1.0 | 100.0% | regressed |'
)
expect(markdown).toContain('- missing metric: missing | candidate')
})
it('fails unsupported artifacts in the CLI', () => {
const dir = makeTempDir()
const baselinePath = writeArtifact(dir, 'baseline.json', { hello: 'world' })
const candidatePath = writeArtifact(dir, 'candidate.json', { hello: 'world' })
const result = spawnSync(
process.execPath,
[scriptPath, '--baseline', baselinePath, '--candidate', candidatePath],
{ cwd: process.cwd(), encoding: 'utf8' }
)
expect(result.status).not.toBe(0)
expect(result.stderr).toContain('unsupported benchmark artifact')
})
})
@@ -1,5 +1,5 @@
import { readFileSync } from 'node:fs'
import { basename } from 'node:path'
import { collectTerminalPerfRows, readJsonReport } from './terminal-perf-report-annotations.mjs'
const reportPaths = process.argv.slice(2)
if (reportPaths[0] === '--') {
@@ -26,55 +26,6 @@ const BUDGETS = {
maxRendererDroppedBacklogs: 0
}
function readJsonReport(path) {
const raw = readFileSync(path, 'utf8')
const start = raw.indexOf('{')
const end = raw.lastIndexOf('}')
if (start === -1 || end <= start) {
throw new Error(`${path}: no JSON object found`)
}
return JSON.parse(raw.slice(start, end + 1))
}
function parseAnnotationDescription(description) {
const values = {}
for (const part of description.split(/\s+/)) {
const index = part.indexOf('=')
if (index === -1) {
continue
}
values[part.slice(0, index)] = part.slice(index + 1)
}
return values
}
function collectTerminalPerfRows(report, source) {
const rows = []
const visitSuite = (suite) => {
for (const spec of suite.specs ?? []) {
for (const test of spec.tests ?? []) {
for (const annotation of test.annotations ?? []) {
if (!annotation.type.startsWith('opencode-')) {
continue
}
rows.push({
source,
scenario: annotation.type,
...parseAnnotationDescription(annotation.description ?? '')
})
}
}
}
for (const child of suite.suites ?? []) {
visitSuite(child)
}
}
for (const suite of report.suites ?? []) {
visitSuite(suite)
}
return rows
}
function parseMs(value, fieldName, row, failures) {
if (value == null || value === '') {
return null
@@ -0,0 +1,394 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { basename, dirname, isAbsolute, relative } from 'node:path'
import { pathToFileURL } from 'node:url'
import { collectTerminalPerfRows } from './terminal-perf-report-annotations.mjs'
const USAGE =
'Usage: node config/scripts/compare-benchmark-artifacts.mjs --baseline <path> --candidate <path> [--title <title>] [--output <path>] [--json-output <path>] [--higher-is-better <metric-key> ...]'
const PLAYWRIGHT_METADATA_FIELDS = new Set(['source', 'scenario', 'panes', 'frames', 'samples'])
export function parseBenchmarkComparisonArgs(argv = process.argv.slice(2)) {
const args = argv[0] === '--' ? argv.slice(1) : [...argv]
const parsed = {
higherIsBetter: new Set(),
title: 'Benchmark comparison'
}
for (let index = 0; index < args.length; index += 1) {
const flag = args[index]
if (flag === '--baseline') {
parsed.baselinePath = readRequiredValue(args, ++index, '--baseline requires a path')
continue
}
if (flag === '--candidate') {
parsed.candidatePath = readRequiredValue(args, ++index, '--candidate requires a path')
continue
}
if (flag === '--title') {
parsed.title = readRequiredValue(args, ++index, '--title requires a value')
continue
}
if (flag === '--output') {
parsed.outputPath = readRequiredValue(args, ++index, '--output requires a path')
continue
}
if (flag === '--json-output') {
parsed.jsonOutputPath = readRequiredValue(args, ++index, '--json-output requires a path')
continue
}
if (flag === '--higher-is-better') {
let consumed = 0
while (args[index + 1] != null && !args[index + 1].startsWith('--')) {
parsed.higherIsBetter.add(args[index + 1])
index += 1
consumed += 1
}
if (consumed === 0) {
throw new Error('--higher-is-better requires a metric key')
}
continue
}
throw new Error(USAGE)
}
if (!parsed.baselinePath) {
throw new Error(USAGE)
}
if (!parsed.candidatePath) {
throw new Error(USAGE)
}
return parsed
}
function readRequiredValue(args, index, message) {
const value = args[index]
if (value == null || value.startsWith('--')) {
throw new Error(message)
}
return value
}
export function readBenchmarkArtifact(path) {
return JSON.parse(readFileSync(path, 'utf8'))
}
export function normalizeBenchmarkArtifact(path, artifact = readBenchmarkArtifact(path)) {
if (artifact?.summaryMedianMs != null) {
return normalizeNumericObject(path, artifact, 'startup', artifact.summaryMedianMs, () => 'ms')
}
if (artifact?.summaryMedian != null) {
return normalizeNumericObject(path, artifact, 'daemon', artifact.summaryMedian, (key) =>
key.endsWith('Count') || key.endsWith('After') ? 'count' : 'ms'
)
}
if (artifact?.suites != null) {
return normalizePlaywrightArtifact(path, artifact)
}
if (artifact?.summary != null) {
return normalizeSummaryArtifact(path, artifact)
}
throw new Error(
`${path}: unsupported benchmark artifact; expected summaryMedianMs, summaryMedian, Playwright suites, or top-level summary`
)
}
function artifactLabel(path, artifact) {
return typeof artifact?.label === 'string' && artifact.label.length > 0
? artifact.label
: basename(path)
}
function normalizeNumericObject(path, artifact, kind, values, unitForKey) {
return {
kind,
label: artifactLabel(path, artifact),
metrics: Object.entries(values ?? {})
.filter(([, value]) => Number.isFinite(value))
.map(([key, value]) => ({
direction: 'lower-is-better',
key,
unit: unitForKey(key),
value
}))
}
}
function normalizePlaywrightArtifact(path, artifact) {
const rows = collectTerminalPerfRows(artifact, basename(path), { typePrefix: 'opencode-' })
const groupedMetrics = new Map()
for (const row of rows) {
for (const [field, rawValue] of Object.entries(row)) {
if (PLAYWRIGHT_METADATA_FIELDS.has(field)) {
continue
}
const parsed = parseMetricValue(rawValue)
if (parsed == null) {
continue
}
const key = `${row.scenario}.${field}`
const metricGroup = groupedMetrics.get(key) ?? {
unit: parsed.unit,
values: []
}
metricGroup.values.push(parsed.value)
groupedMetrics.set(key, metricGroup)
}
}
const metrics = [...groupedMetrics.entries()].map(([key, metricGroup]) => ({
direction: 'lower-is-better',
key,
unit: metricGroup.unit,
value: mean(metricGroup.values)
}))
return {
kind: 'playwright',
label: artifactLabel(path, artifact),
metrics
}
}
function mean(values) {
return values.reduce((sum, value) => sum + value, 0) / values.length
}
function parseMetricValue(rawValue) {
if (typeof rawValue === 'string') {
const msMatch = rawValue.match(/^(-?\d+(?:\.\d+)?)ms$/)
if (msMatch) {
return { unit: 'ms', value: Number(msMatch[1]) }
}
}
const numericValue = Number(rawValue)
if (!Number.isFinite(numericValue)) {
return null
}
return { unit: 'count', value: numericValue }
}
function normalizeSummaryArtifact(path, artifact) {
const metrics = []
flattenSummary(metrics, ['summary'], artifact.summary)
return {
kind: 'summary',
label: artifactLabel(path, artifact),
metrics
}
}
function flattenSummary(metrics, pathParts, value) {
if (Number.isFinite(value)) {
const key = pathParts.join('.')
metrics.push({
direction: 'lower-is-better',
key,
unit: unitForSummaryKey(pathParts),
value
})
return
}
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
return
}
for (const [childKey, childValue] of Object.entries(value)) {
flattenSummary(metrics, [...pathParts, childKey], childValue)
}
}
function unitForSummaryKey(pathParts) {
if (pathParts.some((part) => part.endsWith('CpuPercent'))) {
return '%'
}
if (pathParts.some((part) => part.endsWith('Bytes'))) {
return 'bytes'
}
return ''
}
export function compareBenchmarkArtifacts({
baselinePath,
candidatePath,
title = 'Benchmark comparison',
higherIsBetter = new Set(),
now = () => new Date()
}) {
const baseline = normalizeBenchmarkArtifact(baselinePath)
const candidate = normalizeBenchmarkArtifact(candidatePath)
const candidateMetrics = new Map(candidate.metrics.map((metric) => [metric.key, metric]))
const baselineMetrics = new Map(baseline.metrics.map((metric) => [metric.key, metric]))
const metrics = []
const skippedMetrics = []
for (const baselineMetric of baseline.metrics) {
const candidateMetric = candidateMetrics.get(baselineMetric.key)
if (!isComparableMetric(baselineMetric)) {
skippedMetrics.push({ key: baselineMetric.key, reason: 'missing baseline metric' })
continue
}
if (!isComparableMetric(candidateMetric)) {
skippedMetrics.push({ key: baselineMetric.key, reason: 'missing candidate metric' })
continue
}
if (baselineMetric.unit !== candidateMetric.unit) {
skippedMetrics.push({
key: baselineMetric.key,
reason: `unit mismatch (${formatUnitLabel(baselineMetric.unit)} vs ${formatUnitLabel(candidateMetric.unit)})`
})
continue
}
const direction = higherIsBetter.has(baselineMetric.key)
? 'higher-is-better'
: baselineMetric.direction
metrics.push(compareMetric(baselineMetric, candidateMetric, direction))
}
for (const candidateMetric of candidate.metrics) {
if (!baselineMetrics.has(candidateMetric.key)) {
skippedMetrics.push({ key: candidateMetric.key, reason: 'missing baseline metric' })
}
}
if (metrics.length === 0) {
throw new Error('No comparable benchmark metrics found.')
}
return {
schemaVersion: 1,
createdAt: now().toISOString(),
title,
baseline: {
path: benchmarkDisplayPath(baselinePath),
label: baseline.label,
kind: baseline.kind
},
candidate: {
path: benchmarkDisplayPath(candidatePath),
label: candidate.label,
kind: candidate.kind
},
metrics,
skippedMetrics
}
}
function benchmarkDisplayPath(path, cwd = process.cwd()) {
if (!isAbsolute(path)) {
return path
}
const relativePath = relative(cwd, path)
if (relativePath && !relativePath.startsWith('..') && !isAbsolute(relativePath)) {
return relativePath
}
return basename(path)
}
function isComparableMetric(metric) {
return metric != null && Number.isFinite(metric.value)
}
function compareMetric(baselineMetric, candidateMetric, direction) {
const rawDelta = candidateMetric.value - baselineMetric.value
const absoluteDelta = roundOneDecimal(rawDelta)
const percentDelta =
baselineMetric.value === 0
? null
: roundOneDecimal((rawDelta / Math.abs(baselineMetric.value)) * 100)
return {
key: baselineMetric.key,
unit: baselineMetric.unit,
direction,
baseline: baselineMetric.value,
candidate: candidateMetric.value,
absoluteDelta,
percentDelta,
status: metricStatus(absoluteDelta, direction)
}
}
function formatUnitLabel(unit) {
return unit === '' ? 'none' : unit
}
function roundOneDecimal(value) {
return Math.round(value * 10) / 10
}
function metricStatus(absoluteDelta, direction) {
if (absoluteDelta === 0) {
return 'unchanged'
}
if (direction === 'higher-is-better') {
return absoluteDelta > 0 ? 'improved' : 'regressed'
}
return absoluteDelta < 0 ? 'improved' : 'regressed'
}
export function formatBenchmarkComparisonMarkdown(comparison) {
const lines = [
`# ${markdownText(comparison.title)}`,
'',
`Baseline: ${markdownText(comparison.baseline.label)} (${markdownText(comparison.baseline.path)})`,
`Candidate: ${markdownText(comparison.candidate.label)} (${markdownText(comparison.candidate.path)})`,
`Generated: ${comparison.createdAt}`,
'',
'| Metric | Baseline | Candidate | Delta | Delta % | Result |',
'|---|---:|---:|---:|---:|---|'
]
for (const metric of comparison.metrics) {
lines.push(
`| ${markdownTableCell(metric.key)} | ${formatMetricValue(metric.baseline, metric.unit)} | ${formatMetricValue(metric.candidate, metric.unit)} | ${formatMetricValue(metric.absoluteDelta, metric.unit)} | ${formatPercent(metric.percentDelta)} | ${metric.status} |`
)
}
if (comparison.skippedMetrics.length > 0) {
lines.push('', '## Skipped metrics')
for (const skippedMetric of comparison.skippedMetrics) {
lines.push(`- ${markdownText(skippedMetric.key)}: ${markdownText(skippedMetric.reason)}`)
}
}
return `${lines.join('\n')}\n`
}
function markdownText(value) {
return String(value ?? '')
.replace(/\s*\r?\n\s*/g, ' ')
.replace(/[\\`*_{}<>()#+.!-]|\[|\]/g, '\\$&')
}
function markdownTableCell(value) {
return markdownText(value).replaceAll('|', '\\|')
}
function formatMetricValue(value, unit) {
return `${Number(value).toFixed(1)}${unit}`
}
function formatPercent(value) {
return value == null ? '' : `${value.toFixed(1)}%`
}
export function runBenchmarkComparisonCli(args = {}) {
const argv = args.argv ?? process.argv.slice(2)
const parsed = parseBenchmarkComparisonArgs(argv)
const comparison = compareBenchmarkArtifacts(parsed)
const markdown = formatBenchmarkComparisonMarkdown(comparison)
if (parsed.outputPath) {
mkdirSync(dirname(parsed.outputPath), { recursive: true })
writeFileSync(parsed.outputPath, markdown)
}
if (parsed.jsonOutputPath) {
mkdirSync(dirname(parsed.jsonOutputPath), { recursive: true })
writeFileSync(parsed.jsonOutputPath, `${JSON.stringify(comparison, null, 2)}\n`)
}
const stdout = args.stdout ?? process.stdout
stdout.write(markdown)
return comparison
}
if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
runBenchmarkComparisonCli()
} catch (error) {
console.error(error.message)
process.exit(1)
}
}
@@ -86,6 +86,32 @@ describe('Electron runtime package contract', () => {
)
})
it('publishes both Linux release matrix entries', () => {
const releaseWorkflow = readFileSync(
join(projectDir, '.github/workflows/release-cut.yml'),
'utf8'
)
const parsedWorkflow = parse(releaseWorkflow)
const publishLinuxStep = parsedWorkflow.jobs.build.steps.find(
(step) => step.name === 'Publish release artifacts (Linux)'
)
expect(publishLinuxStep.if).toContain("matrix.platform == 'linux-x64'")
expect(publishLinuxStep.if).toContain("matrix.platform == 'linux-arm64'")
expect(publishLinuxStep.with.command).toBe('${{ matrix.release_command }}')
})
it('keeps Linux postinstall repairing Chromium sandbox permissions', () => {
const afterInstallScript = readFileSync(
join(projectDir, 'resources/linux/packaging/after-install.sh'),
'utf8'
)
expect(afterInstallScript).toContain('chrome-sandbox')
expect(afterInstallScript).toContain('chmod 4755 "$sandbox"')
expect(afterInstallScript).not.toContain('chmod 0755 "$sandbox"')
})
it('lets release-cut tag a version that is already present on main', () => {
const releaseWorkflow = readFileSync(
join(projectDir, '.github/workflows/release-cut.yml'),
@@ -0,0 +1,45 @@
import { spawnSync } from 'node:child_process'
const extraArgs = process.argv.slice(2)
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const env = {
...process.env,
ORCA_E2E_SSH_DOCKER: '1'
}
const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], {
stdio: 'inherit',
env
})
if (runtime.error) {
throw runtime.error
}
if (runtime.status !== 0) {
process.exit(runtime.status ?? 1)
}
const result = spawnSync(
pnpm,
[
'exec',
'playwright',
'test',
'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
'electron-headless',
'--workers=1',
...extraArgs
],
{
stdio: 'inherit',
env
}
)
if (result.error) {
throw result.error
}
process.exit(result.status ?? 1)
@@ -1,5 +1,5 @@
import { readFileSync } from 'node:fs'
import { basename } from 'node:path'
import { collectTerminalPerfRows, readJsonReport } from './terminal-perf-report-annotations.mjs'
const reportPaths = process.argv.slice(2)
if (reportPaths[0] === '--') {
@@ -13,55 +13,6 @@ if (reportPaths.length === 0) {
process.exit(1)
}
function readJsonReport(path) {
const raw = readFileSync(path, 'utf8')
const start = raw.indexOf('{')
const end = raw.lastIndexOf('}')
if (start === -1 || end <= start) {
throw new Error(`${path}: no JSON object found`)
}
return JSON.parse(raw.slice(start, end + 1))
}
function parseAnnotationDescription(description) {
const values = {}
for (const part of description.split(/\s+/)) {
const index = part.indexOf('=')
if (index === -1) {
continue
}
values[part.slice(0, index)] = part.slice(index + 1)
}
return values
}
function collectTerminalPerfRows(report, source) {
const rows = []
const visitSuite = (suite) => {
for (const spec of suite.specs ?? []) {
for (const test of spec.tests ?? []) {
for (const annotation of test.annotations ?? []) {
if (!annotation.type.startsWith('opencode-')) {
continue
}
rows.push({
source,
scenario: annotation.type,
...parseAnnotationDescription(annotation.description ?? '')
})
}
}
}
for (const child of suite.suites ?? []) {
visitSuite(child)
}
}
for (const suite of report.suites ?? []) {
visitSuite(suite)
}
return rows
}
function markdownCell(value) {
return String(value ?? '').replaceAll('|', '\\|')
}
@@ -0,0 +1,63 @@
import { execFileSync } from 'node:child_process'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
const scriptPath = 'config/scripts/summarize-terminal-perf-report.mjs'
const tempDirs = []
function writeReport() {
const dir = mkdtempSync(join(tmpdir(), 'orca-terminal-perf-summary-'))
tempDirs.push(dir)
const reportPath = join(dir, 'report.json')
writeFileSync(
reportPath,
JSON.stringify({
suites: [
{
specs: [
{
tests: [
{
annotations: [
{
type: 'opencode-scale',
description: 'panes=50 frames=60 median=12.3ms rendererQueuedChars=1000'
},
{
type: 'browser-unrelated',
description: 'panes=1 median=999.0ms'
}
]
}
]
}
]
}
]
})
)
return reportPath
}
afterEach(() => {
while (tempDirs.length > 0) {
rmSync(tempDirs.pop(), { force: true, recursive: true })
}
})
describe('summarize-terminal-perf-report', () => {
it('prints only OpenCode terminal perf annotation rows', () => {
const output = execFileSync(process.execPath, [scriptPath, writeReport()], {
cwd: process.cwd(),
encoding: 'utf8'
})
expect(output).toContain('| Source | Scenario | Panes | Frames | Median |')
expect(output).toContain('| report.json | opencode-scale | 50 | 60 | 12.3ms |')
expect(output).toContain('1000')
expect(output).not.toContain('browser-unrelated')
expect(output).not.toContain('999.0ms')
})
})
@@ -0,0 +1,53 @@
import { readFileSync } from 'node:fs'
export function readJsonReport(path) {
const raw = readFileSync(path, 'utf8')
const start = raw.indexOf('{')
const end = raw.lastIndexOf('}')
if (start === -1 || end <= start) {
throw new Error(`${path}: no JSON object found`)
}
return JSON.parse(raw.slice(start, end + 1))
}
export function parseAnnotationDescription(description) {
const values = {}
for (const part of description.split(/\s+/)) {
const index = part.indexOf('=')
if (index === -1) {
continue
}
values[part.slice(0, index)] = part.slice(index + 1)
}
return values
}
export function collectTerminalPerfRows(report, source, options = {}) {
const { typePrefix = 'opencode-' } = options
const rows = []
const visitSuite = (suite) => {
for (const spec of suite.specs ?? []) {
for (const test of spec.tests ?? []) {
for (const annotation of test.annotations ?? []) {
if (!annotation.type.startsWith(typePrefix)) {
continue
}
rows.push({
...parseAnnotationDescription(annotation.description ?? ''),
// Why: annotation descriptions are artifact-controlled; keep the
// trusted report source and annotation type from being relabeled.
source,
scenario: annotation.type
})
}
}
}
for (const child of suite.suites ?? []) {
visitSuite(child)
}
}
for (const suite of report.suites ?? []) {
visitSuite(suite)
}
return rows
}
@@ -0,0 +1,144 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
collectTerminalPerfRows,
parseAnnotationDescription,
readJsonReport
} from './terminal-perf-report-annotations.mjs'
const tempDirs = []
function makeReportPath(content) {
const dir = mkdtempSync(join(tmpdir(), 'orca-terminal-perf-annotations-'))
tempDirs.push(dir)
const reportPath = join(dir, 'report.json')
writeFileSync(reportPath, content)
return reportPath
}
function makeNestedReport() {
return {
suites: [
{
specs: [
{
tests: [
{
annotations: [
{
type: 'browser-unrelated',
description: 'median=999.0ms'
}
]
}
]
}
],
suites: [
{
specs: [
{
tests: [
{
annotations: [
{
type: 'opencode-scale',
description: 'panes=50 median=12.3ms ignored-token rendererQueuedChars=1000'
},
{
type: 'terminal-scale',
description: 'panes=25 median=7.0ms'
}
]
}
]
}
]
}
]
}
]
}
}
afterEach(() => {
while (tempDirs.length > 0) {
rmSync(tempDirs.pop(), { force: true, recursive: true })
}
})
describe('terminal perf report annotations', () => {
it('parses key-value annotation description segments only', () => {
expect(parseAnnotationDescription('panes=50 no-equals median=12.3ms name=a=b')).toEqual({
median: '12.3ms',
name: 'a=b',
panes: '50'
})
})
it('reads JSON reports surrounded by noisy process output', () => {
const reportPath = makeReportPath(
`noise before\n${JSON.stringify({ suites: [] })}\nnoise after`
)
expect(readJsonReport(reportPath)).toEqual({ suites: [] })
})
it('collects nested OpenCode annotations by default', () => {
expect(collectTerminalPerfRows(makeNestedReport(), 'report.json')).toEqual([
{
median: '12.3ms',
panes: '50',
rendererQueuedChars: '1000',
scenario: 'opencode-scale',
source: 'report.json'
}
])
})
it('keeps trusted source and scenario fields when descriptions contain matching keys', () => {
const report = {
suites: [
{
specs: [
{
tests: [
{
annotations: [
{
type: 'opencode-scale',
description: 'source=spoofed.json scenario=spoofed median=12.3ms'
}
]
}
]
}
]
}
]
}
expect(collectTerminalPerfRows(report, 'report.json')).toEqual([
{
median: '12.3ms',
scenario: 'opencode-scale',
source: 'report.json'
}
])
})
it('supports a custom annotation type prefix', () => {
expect(
collectTerminalPerfRows(makeNestedReport(), 'report.json', { typePrefix: 'terminal-' })
).toEqual([
{
median: '7.0ms',
panes: '25',
scenario: 'terminal-scale',
source: 'report.json'
}
])
})
})
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 1.4m">
<title>downloads: 1.4m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 1.5m">
<title>downloads: 1.5m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">1.4m</text>
<text x="93.5" y="14">1.4m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">1.5m</text>
<text x="93.5" y="14">1.5m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

+2 -2
View File
@@ -35,7 +35,7 @@
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -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)
---
+2 -2
View File
@@ -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)
</td>
<td width="50%">
@@ -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)
---
+2 -2
View File
@@ -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)
</td>
<td width="50%">
@@ -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)
---
+2 -2
View File
@@ -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)
</td>
<td width="50%">
@@ -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)
---
@@ -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"
/>
</Pressable>
) : (
@@ -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
@@ -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"')
})
})
@@ -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<String>?
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<String>()
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<String>? {
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<String> = [
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<String> = [
@@ -1697,24 +1976,6 @@ private let valueSettableRoles: Set<String> = [
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<String> = [
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<String>()
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: " ")
}
@@ -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<String>?) -> 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
@@ -2,6 +2,24 @@ import XCTest
@testable import OrcaComputerUseMacOSCore
final class SnapshotRenderingTests: XCTestCase {
func testSkipsUnsupportedAdvertisedAttributes() {
let advertised: Set<String> = ["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)
+7 -2
View File
@@ -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",
@@ -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.
+4 -1
View File
@@ -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:<worktreeId> --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:<worktreeId> --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 <id>`; defaults are `todo`, `in-progress`, `in-review`, `completed`.
## Terminals
Common commands:
+12 -1
View File
@@ -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()
}))
@@ -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')
+30 -1
View File
@@ -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<string, str
return `if [ -x ${quoted} ]; then ${invocation}; fi`
}
function quotePowerShellString(value: string): string {
return `'${value.replaceAll("'", "''")}'`
}
// Why: Windows splits a raw hook command on whitespace, so a user profile path
// like `C:\Users\Jane Doe` makes the agent try to execute `C:\Users\Jane` and
// fail with exit code 1. Keep the script path inside an encoded PowerShell
// command so cmd.exe never gets a chance to expand legal path characters like
// `%` or `^` before the .cmd is invoked. #6078.
export function wrapWindowsHookCommand(scriptPath: string): string {
const command = `& ${quotePowerShellString(scriptPath)}; exit $LASTEXITCODE`
const encodedCommand = Buffer.from(command, 'utf16le').toString('base64')
return `powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}`
}
export function buildWindowsAgentHookPostCommand(source: AgentHookSource): string {
// Why: Windows PowerShell 5.1 defaults redirected stdin/request bodies to the
// active code page. Hook payloads are UTF-8 JSON, so force UTF-8 on both read
@@ -7,11 +7,10 @@ import {
parseRovoSessionFile
} from './session-scanner-graph-parsers'
import { parseKimiSessionFile } from './session-scanner-kimi-parser'
import {
parseClaudeSessionFile,
parseCodexSessionFile,
parseGeminiSessionFile
} from './session-scanner-primary-parsers'
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
import { parseClaudeSessionFile, parseGeminiSessionFile } from './session-scanner-primary-parsers'
import { parseCodexSessionFile } from './session-scanner-codex-parser'
import {
parseCopilotSessionFile,
parseCursorSessionFile,
@@ -20,6 +19,15 @@ import {
} from './session-scanner-secondary-parsers'
import type { SessionFileCandidate } from './session-scanner-types'
/**
* Parse a single agent session file into an `AiVaultSession`. Routes to the
* appropriate agent-specific parser based on `candidate.agent`. For OpenCode
* SQLite candidates (synthetic `db#id` paths), routes to
* `parseOpenCodeSqliteSession` instead of the legacy JSON parser.
* @param candidate - The session file candidate to parse.
* @param platform - The platform to use for resume command generation.
* @returns The parsed `AiVaultSession`, or `null` if parsing fails.
*/
export async function parseAgentSessionFile(
candidate: SessionFileCandidate,
platform: NodeJS.Platform
@@ -35,8 +43,20 @@ export async function parseAgentSessionFile(
return parseCopilotSessionFile(candidate.file, platform)
case 'cursor':
return parseCursorSessionFile(candidate.file, platform)
case 'opencode':
case 'opencode': {
// Why: OpenCode 1.17.x sessions are read from SQLite via a synthetic
// <dbPath>#<sessionId> 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':
@@ -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)
})
})
@@ -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<string, string>
}
const codexSessionIndexTitleCache = new Map<string, Promise<CodexSessionIndexTitleCacheEntry>>()
export async function parseCodexSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform,
codexHome: string | null = null
): Promise<AiVaultSession | null> {
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, unknown>): string | null {
return extractString(payload.thread_source) ?? extractString(payload.threadSource)
}
function isCodexWorkerSession(payload: Record<string, unknown>): 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, unknown>): 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<string | null> {
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<Map<string, string>> {
const indexPath = join(codexHome, CODEX_SESSION_INDEX_FILE)
let signature: string
try {
const indexStat = await stat(indexPath)
signature = `${indexStat.size}:${indexStat.mtimeMs}`
} catch {
return new Map()
}
const cached = codexSessionIndexTitleCache.get(codexHome)
if (cached) {
const entry = await cached
if (entry.signature === signature) {
return entry.titles
}
}
const pending = readCodexSessionIndexTitlesFromDisk(indexPath).then((titles) => ({
signature,
titles
}))
codexSessionIndexTitleCache.set(codexHome, pending)
return (await pending).titles
}
async function readCodexSessionIndexTitlesFromDisk(
indexPath: string
): Promise<Map<string, string>> {
const titleBySessionId = new Map<string, string>()
try {
const lines = createInterface({
input: createReadStream(indexPath, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const line of lines) {
const record = parseJsonObject(line)
if (!record) {
continue
}
const sessionId = extractString(record.id)
const title = normalizeTitleText(extractString(record.thread_name) ?? '')
if (sessionId && title) {
titleBySessionId.set(sessionId, title)
}
}
} catch {
// Codex creates the index opportunistically; older homes may only have raw transcripts.
}
return titleBySessionId
}
@@ -109,6 +109,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => {
copilotSessionsDir: join(root, 'copilot-sessions'),
cursorProjectsDir: join(root, 'cursor-projects'),
opencodeStorageDir: join(root, 'opencode-storage'),
opencodeDbPaths: [],
grokSessionsDir: join(root, 'grok-sessions'),
devinTranscriptsDir: join(root, 'devin-transcripts'),
hermesSessionsDir: join(root, 'hermes-sessions'),
@@ -0,0 +1,73 @@
import { readdir } from 'fs/promises'
import { homedir } from 'os'
import { dirname, join } from 'path'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { listOpenCodeDatabases } from '../opencode-usage/scanner'
import { discoverOpenCodeSessions } from './session-scanner-opencode-sqlite-discovery'
import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types'
const OPENCODE_STORAGE_DIR = join(
process.env.OPENCODE_CONFIG_DIR?.trim() || join(homedir(), '.local', 'share', 'opencode'),
'storage'
)
export function opencodeDiscoveries(
options: AiVaultScanOptions,
wslHomeDirs: readonly string[],
limit: number,
issues: AiVaultScanIssue[]
): Promise<SessionFileDiscovery>[] {
const storageDirs = opencodeStorageDirs(options, wslHomeDirs)
return storageDirs.map(async (storageDir, index) =>
discoverOpenCodeSessions({
storageDir,
dbPaths: await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index),
limitPerAgent: limit,
issues
})
)
}
function opencodeStorageDirs(
options: AiVaultScanOptions,
wslHomeDirs: readonly string[]
): string[] {
return [
options.opencodeStorageDir ?? OPENCODE_STORAGE_DIR,
...wslHomeDirs.map((homeDir) => join(homeDir, '.local', 'share', 'opencode', 'storage'))
]
}
async function opencodeDbPathsForSource(
options: AiVaultScanOptions,
wslHomeDirs: readonly string[],
storageDir: string,
sourceIndex: number
): Promise<readonly string[]> {
if (options.opencodeDbPaths) {
return sourceIndex === 0 ? options.opencodeDbPaths : []
}
// Why: custom OpenCode storage roots still keep SQLite DBs in the parent data dir.
if (sourceIndex === 0 && options.opencodeStorageDir) {
return listOpenCodeDatabasesInDirectory(dirname(storageDir))
}
if (sourceIndex === 0) {
return listOpenCodeDatabases()
}
const wslHomeDir = wslHomeDirs[sourceIndex - 1]
return wslHomeDir
? listOpenCodeDatabasesInDirectory(join(wslHomeDir, '.local', 'share', 'opencode'))
: []
}
async function listOpenCodeDatabasesInDirectory(dataDir: string): Promise<string[]> {
try {
const entries = await readdir(dataDir, { withFileTypes: true })
return entries
.filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name))
.map((entry) => join(dataDir, entry.name))
.sort()
} catch {
return []
}
}
@@ -0,0 +1,215 @@
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import { scanAiVaultSessions } from './session-scanner'
import Database from '../sqlite/sync-database'
let tempRoots: string[] = []
let tempDbDirs: string[] = []
afterEach(async () => {
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
for (const dir of tempDbDirs) {
rmSync(dir, { recursive: true, force: true })
}
tempRoots = []
tempDbDirs = []
})
function isolatedScanRoots(root: string) {
return {
claudeProjectsDir: join(root, 'claude-projects'),
codexSessionsDir: join(root, 'codex-sessions'),
geminiSessionsDir: join(root, 'gemini-sessions'),
copilotSessionsDir: join(root, 'copilot-sessions'),
cursorProjectsDir: join(root, 'cursor-projects'),
opencodeStorageDir: join(root, 'opencode-storage'),
opencodeDbPaths: [] as readonly string[],
grokSessionsDir: join(root, 'grok-sessions'),
devinTranscriptsDir: join(root, 'devin-transcripts'),
hermesSessionsDir: join(root, 'hermes-sessions'),
rovoSessionsDir: join(root, 'rovo-sessions'),
openclawStateDir: join(root, 'openclaw-state'),
openclawLegacyStateDir: join(root, 'openclaw-legacy-state'),
piSessionsDir: join(root, 'pi-sessions'),
droidSessionsDir: join(root, 'droid-sessions'),
droidProjectsDir: join(root, 'droid-projects'),
kimiSessionsDir: join(root, 'kimi-sessions')
}
}
function createTempOpenCodeDb(): { db: Database.Database; path: string } {
const dir = mkdtempSync(join(tmpdir(), 'orca-ai-vault-sqlite-'))
tempDbDirs.push(dir)
const path = join(dir, 'opencode.db')
return { db: new Database(path), path }
}
function applyOpenCodeSchema(db: Database.Database): void {
db.exec(`
CREATE TABLE session (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
parent_id TEXT,
slug TEXT NOT NULL,
directory TEXT NOT NULL,
title TEXT NOT NULL,
version TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
time_archived INTEGER,
model TEXT,
agent TEXT,
cost REAL DEFAULT 0 NOT NULL,
tokens_input INTEGER DEFAULT 0 NOT NULL,
tokens_output INTEGER DEFAULT 0 NOT NULL,
tokens_reasoning INTEGER DEFAULT 0 NOT NULL,
tokens_cache_read INTEGER DEFAULT 0 NOT NULL,
tokens_cache_write INTEGER DEFAULT 0 NOT NULL,
metadata TEXT
);
CREATE TABLE message (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE part (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL,
session_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
data TEXT NOT NULL
);
`)
}
describe('scanAiVaultSessions — OpenCode SQLite + legacy file coexistence', () => {
it('discovers SQLite sessions next to a custom OpenCode storage directory', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-custom-opencode-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
const opencodeDataDir = join(root, 'custom-opencode')
const opencodeStorageDir = join(opencodeDataDir, 'storage')
await mkdir(opencodeStorageDir, { recursive: true })
const dbPath = join(opencodeDataDir, 'opencode.db')
const db = new Database(dbPath)
applyOpenCodeSchema(db)
db.prepare(
`INSERT INTO session (id, project_id, slug, directory, title, version,
time_created, time_updated, model, agent, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write)
VALUES ('custom-db-session', 'proj-1', 'slug', '/tmp/custom-opencode',
'Custom SQLite session', '1.0.0',
1777634010000, 1777634011000, NULL, 'build', 0,
8, 13, 21, 34, 0)`
).run()
db.close()
const result = await scanAiVaultSessions({
...roots,
opencodeStorageDir,
opencodeDbPaths: undefined,
platform: 'darwin',
limit: 50
})
const session = result.sessions.find((s) => s.sessionId === 'custom-db-session')
expect(session).toBeDefined()
expect(session!.agent).toBe('opencode')
expect(session!.title).toBe('Custom SQLite session')
expect(session!.filePath).toBe(dbPath)
expect(session!.totalTokens).toBe(42)
})
it('surfaces SQLite sessions alongside legacy file sessions and dedups by sessionId', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-mixed-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
// Legacy file session under storage/session/<projectId>/<sessionId>.json
await mkdir(join(roots.opencodeStorageDir, 'session', 'project'), { recursive: true })
await mkdir(join(roots.opencodeStorageDir, 'message', 'legacy-session'), { recursive: true })
await writeFile(
join(roots.opencodeStorageDir, 'session', 'project', 'legacy-session.json'),
JSON.stringify({
id: 'legacy-session',
directory: '/tmp/legacy',
title: 'Legacy file session',
time: { created: 1_777_634_000_000, updated: 1_777_634_001_000 }
})
)
await writeFile(
join(roots.opencodeStorageDir, 'message', 'legacy-session', 'msg_1.json'),
JSON.stringify({
role: 'user',
summary: { title: 'Legacy file session' },
time: { created: 1_777_634_000_000 },
tokens: { input: 5, output: 2 }
})
)
// SQLite session — same sessionId as the legacy file (dedup should keep SQLite)
const { db, path: dbPath } = createTempOpenCodeDb()
applyOpenCodeSchema(db)
db.prepare(
`INSERT INTO session (id, project_id, slug, directory, title, version,
time_created, time_updated, model, agent, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write)
VALUES ('legacy-session', 'proj-1', 'slug', '/tmp/sqlite', 'SQLite session', '1.0.0',
1777634002000, 1777634003000, ?, 'build', 0,
100, 40, 10, 5, 0)`
).run(JSON.stringify({ id: 'glm-5.2', providerID: 'zai-coding-plan' }))
db.prepare(
`INSERT INTO message (id, session_id, time_created, time_updated, data)
VALUES ('msg_sql_1', 'legacy-session', 1777634002500, 1777634002500, ?)`
).run(JSON.stringify({ role: 'user', time: { created: 1_777_634_002_500 } }))
db.prepare(
`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data)
VALUES ('prt_sql_1', 'msg_sql_1', 'legacy-session', 1777634002500, 1777634002500, ?)`
).run(JSON.stringify({ type: 'text', text: 'sqlite hello' }))
db.close()
// A second SQLite-only session
const { db: db2, path: dbPath2 } = createTempOpenCodeDb()
applyOpenCodeSchema(db2)
db2
.prepare(
`INSERT INTO session (id, project_id, slug, directory, title, version,
time_created, time_updated, model, agent, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write)
VALUES ('sqlite-only', 'proj-1', 'slug2', '/tmp/sqlite-only', 'SQLite only', '1.0.0',
1777634004000, 1777634005000, NULL, 'build', 0,
50, 20, 0, 0, 0)`
)
.run()
db2.close()
const result = await scanAiVaultSessions({
...roots,
opencodeDbPaths: [dbPath, dbPath2],
platform: 'darwin',
limit: 50
})
const opencodeSessions = result.sessions.filter((s) => s.agent === 'opencode')
const sessionIds = opencodeSessions.map((s) => s.sessionId).sort()
expect(sessionIds).toEqual(['legacy-session', 'sqlite-only'])
// Why: dedup keeps the SQLite entry (newer time_updated, source of truth)
const legacyEntry = opencodeSessions.find((s) => s.sessionId === 'legacy-session')
expect(legacyEntry).toBeDefined()
expect(legacyEntry!.title).toBe('SQLite session')
expect(legacyEntry!.cwd).toBe('/tmp/sqlite')
expect(legacyEntry!.filePath).toBe(dbPath)
expect(legacyEntry!.totalTokens).toBe(150)
expect(legacyEntry!.resumeCommand).toBe(
"cd '/tmp/sqlite' && opencode --session 'legacy-session'"
)
})
})
@@ -0,0 +1,242 @@
import { basename, extname, join } from 'path'
import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types'
import { discoverFiles } from './session-scanner-discovery'
import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths'
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
import type {
FileWithMtime,
SessionFileCandidate,
SessionFileDiscovery
} from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
import SyncDatabase from '../sqlite/sync-database'
import { columnExists, tableExists } from '../opencode-usage/schema-helpers'
// Why: keep the SQLite discovery + dedup layer separate from the parser so
// each file stays under the max-lines lint rule and the discovery layer can
// be tested in isolation.
type SessionRow = {
id: string
title: string | null
directory: string | null
time_created: number
time_updated: number
model_json: string | null
agent: string | null
tokens_input: number
tokens_output: number
tokens_reasoning: number
tokens_cache_read: number
cost: number
message_count: number
}
function openReadonlyDatabase(dbPath: string): SyncDatabase {
const db = new SyncDatabase(dbPath, { readonly: true, fileMustExist: true })
db.pragma('query_only = ON')
return db
}
function canReadOpenCodeSessions(db: SyncDatabase): boolean {
return (
tableExists(db, 'session') &&
columnExists(db, 'session', 'time_created') &&
columnExists(db, 'session', 'time_updated')
)
}
function sessionColumnSelect(db: SyncDatabase, columnName: string): string {
return columnExists(db, 'session', columnName) ? `s.${columnName}` : 'NULL'
}
function canCountOpenCodeMessages(db: SyncDatabase): boolean {
return (
tableExists(db, 'message') &&
columnExists(db, 'message', 'session_id') &&
columnExists(db, 'message', 'data')
)
}
function buildSessionListQuery(db: SyncDatabase): string {
const modelSelect = sessionColumnSelect(db, 'model')
const agentSelect = sessionColumnSelect(db, 'agent')
const tokenColumns = ['tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read']
const tokenSelects = tokenColumns
.map((col) => `${columnExists(db, 'session', col) ? `s.${col}` : '0'} AS ${col}`)
.join(', ')
const costSelect = columnExists(db, 'session', 'cost') ? 's.cost' : '0'
const parentIdPredicate = columnExists(db, 'session', 'parent_id')
? 'AND s.parent_id IS NULL'
: ''
const archivedPredicate = columnExists(db, 'session', 'time_archived')
? 'AND s.time_archived IS NULL'
: ''
const messageCountSubquery = canCountOpenCodeMessages(db)
? `(SELECT COUNT(*) FROM message m
WHERE m.session_id = s.id
AND json_extract(m.data, '$.role') IN ('user','assistant'))`
: '0'
return `SELECT s.id,
${sessionColumnSelect(db, 'title')} AS title,
${sessionColumnSelect(db, 'directory')} AS directory,
s.time_created,
s.time_updated,
${modelSelect} AS model_json, ${agentSelect} AS agent,
${tokenSelects}, ${costSelect} AS cost,
${messageCountSubquery} AS message_count
FROM session s
WHERE 1=1 ${parentIdPredicate} ${archivedPredicate}
ORDER BY s.time_updated DESC
LIMIT ?`
}
function rowToCandidate(row: SessionRow, dbPath: string): SessionFileCandidate {
const mtimeMs =
typeof row.time_updated === 'number' && row.time_updated > 0
? row.time_updated
: row.time_created
return {
agent: 'opencode' as AiVaultAgent,
file: {
path: buildOpenCodeSqliteCandidatePath(dbPath, row.id),
mtimeMs,
modifiedAt: new Date(mtimeMs).toISOString()
},
codexHome: null
}
}
function dedupeAndSortSqliteCandidates(candidates: SessionFileCandidate[]): SessionFileCandidate[] {
const candidatesBySessionId = new Map<string, SessionFileCandidate>()
for (const candidate of candidates) {
const parsed = splitOpenCodeSqliteCandidate(candidate.file.path)
if (!parsed) {
continue
}
const previous = candidatesBySessionId.get(parsed.sessionId)
if (!previous || candidate.file.mtimeMs > previous.file.mtimeMs) {
candidatesBySessionId.set(parsed.sessionId, candidate)
}
}
return [...candidatesBySessionId.values()].sort((left, right) => {
return right.file.mtimeMs - left.file.mtimeMs
})
}
/**
* List OpenCode sessions from one or more SQLite databases as synthetic
* `SessionFileCandidate` entries. Each candidate's file path is a synthetic
* `<dbPath>#<sessionId>` string that the parser dispatcher routes to
* `parseOpenCodeSqliteSession`. Databases that lack the `session` table are
* silently skipped; errors are recorded as scan issues.
* @param args.dbPaths - Absolute paths to opencode.db files to scan.
* @param args.limit - Maximum number of sessions to return per database.
* @param args.issues - Collected scan issues to append errors to.
* @returns Array of synthetic candidates sorted by `time_updated` DESC.
*/
export async function listOpenCodeSqliteSessions(args: {
dbPaths: readonly string[]
limit: number
issues: AiVaultScanIssue[]
}): Promise<SessionFileCandidate[]> {
const candidates: SessionFileCandidate[] = []
for (const dbPath of args.dbPaths) {
let db: SyncDatabase | null = null
try {
db = openReadonlyDatabase(dbPath)
if (!canReadOpenCodeSessions(db)) {
continue
}
const rows = db.prepare(buildSessionListQuery(db)).all(args.limit) as SessionRow[]
for (const row of rows) {
candidates.push(rowToCandidate(row, dbPath))
}
} catch (err) {
args.issues.push({
agent: 'opencode',
path: dbPath,
message: errorMessage(err)
})
} finally {
db?.close()
}
}
return dedupeAndSortSqliteCandidates(candidates)
}
// Why: extract the sessionId from a legacy file path like
// storage/session/<projectId>/<sessionId>.json. Falls back to the filename
// without extension when the opencode id format doesn't match a UUID.
function sessionIdFromLegacyFilePath(filePath: string): string {
return basename(filePath, extname(filePath))
}
/**
* Discover OpenCode sessions from both the legacy file layout and the SQLite
* DB, deduplicating at the file level before parsing. On mixed installs the
* same session may appear once via a stale legacy JSON file and once via the
* SQLite DB; SQLite is the source of truth on 1.17.x, so file-based entries
* whose sessionId matches a SQLite entry are dropped. Legacy installs without
* the `session` table fall through to the file scanner unchanged.
* @param args.storageDir - Root of the OpenCode storage directory (contains `session/` and `message/`).
* @param args.dbPaths - Absolute paths to opencode.db files to scan.
* @param args.limitPerAgent - Maximum number of candidates per source.
* @param args.issues - Collected scan issues to append errors to.
* @returns A `SessionFileDiscovery` with deduplicated file entries.
*/
export async function discoverOpenCodeSessions(args: {
storageDir: string
dbPaths: readonly string[]
limitPerAgent: number
issues: AiVaultScanIssue[]
}): Promise<SessionFileDiscovery> {
const [fileDiscovery, sqliteCandidates] = await Promise.all([
discoverFiles({
rootDir: join(args.storageDir, 'session'),
limit: args.limitPerAgent,
agent: 'opencode',
issues: args.issues,
extensions: ['.json']
}),
listOpenCodeSqliteSessions({
dbPaths: args.dbPaths,
limit: args.limitPerAgent,
issues: args.issues
})
])
const sqliteFiles = sqliteCandidates.map((c) => c.file)
// Why: on mixed installs the same OpenCode session may appear once via the
// SQLite DB and once via a stale legacy JSON file. SQLite is the source of
// truth on 1.17.x, so drop file-based duplicates when a SQLite entry with
// the same sessionId already exists. Deduping at the file level also avoids
// parsing the same session twice.
if (sqliteFiles.length === 0) {
return {
agent: 'opencode' as const,
rootDir: fileDiscovery.rootDir,
files: fileDiscovery.files
}
}
const sqliteSessionIds = new Set<string>()
for (const file of sqliteFiles) {
const parsed = splitOpenCodeSqliteCandidate(file.path)
if (parsed) {
sqliteSessionIds.add(parsed.sessionId)
}
}
const dedupedFileDiscovery: FileWithMtime[] = []
for (const file of fileDiscovery.files) {
if (!sqliteSessionIds.has(sessionIdFromLegacyFilePath(file.path))) {
dedupedFileDiscovery.push(file)
}
}
return {
agent: 'opencode' as const,
rootDir: fileDiscovery.rootDir,
files: [...dedupedFileDiscovery, ...sqliteFiles]
}
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import {
buildOpenCodeSqliteCandidatePath,
looksLikeOpenCodeSqliteCandidate,
splitOpenCodeSqliteCandidate
} from './session-scanner-opencode-sqlite-paths'
describe('splitOpenCodeSqliteCandidate', () => {
it('splits a synthetic db#sessionId path', () => {
const result = splitOpenCodeSqliteCandidate('/data/opencode.db#ses_abc')
expect(result).toEqual({ dbPath: '/data/opencode.db', sessionId: 'ses_abc' })
})
it('splits a stable-db path', () => {
const result = splitOpenCodeSqliteCandidate('/data/opencode-stable.db#ses_xyz')
expect(result).toEqual({ dbPath: '/data/opencode-stable.db', sessionId: 'ses_xyz' })
})
it('rejects a path whose db basename is not opencode*.db', () => {
expect(splitOpenCodeSqliteCandidate('/data/random.db#ses_abc')).toBeNull()
expect(splitOpenCodeSqliteCandidate('/data/notes.txt#ses_abc')).toBeNull()
})
it('rejects a path without a separator', () => {
expect(splitOpenCodeSqliteCandidate('/data/opencode.db')).toBeNull()
})
it('rejects an empty sessionId', () => {
expect(splitOpenCodeSqliteCandidate('/data/opencode.db#')).toBeNull()
})
})
describe('looksLikeOpenCodeSqliteCandidate', () => {
it('returns true for a synthetic path', () => {
expect(looksLikeOpenCodeSqliteCandidate('/x/opencode.db#ses_1')).toBe(true)
})
it('returns false for a real filesystem path', () => {
expect(looksLikeOpenCodeSqliteCandidate('/x/storage/session/proj/ses_1.json')).toBe(false)
})
})
describe('buildOpenCodeSqliteCandidatePath', () => {
it('joins dbPath and sessionId with #', () => {
expect(buildOpenCodeSqliteCandidatePath('/d/opencode.db', 'ses_1')).toBe('/d/opencode.db#ses_1')
})
})
@@ -0,0 +1,56 @@
import { basename } from 'path'
// Why: keep the synthetic candidate-path helpers separate from the SQLite
// discovery/parser so both the scanner and the agent-parser dispatcher can
// import them without pulling in the SyncDatabase dependency.
const OPENCODE_SQLITE_PATH_SEPARATOR = '#'
/**
* Build a synthetic candidate path that encodes the SQLite DB path and session ID
* as `<dbPath>#<sessionId>`. Used by the discovery layer so SQLite-backed
* sessions flow through the same FileWithMtime pipeline as file-backed ones.
* @param dbPath - Absolute path to the opencode.db file.
* @param sessionId - The OpenCode session ID (primary key in the session table).
* @returns The synthetic candidate path string.
*/
export function buildOpenCodeSqliteCandidatePath(dbPath: string, sessionId: string): string {
return `${dbPath}${OPENCODE_SQLITE_PATH_SEPARATOR}${sessionId}`
}
/**
* Parse a synthetic candidate path back into its DB path and session ID parts.
* Validates that the DB basename matches `opencode*.db` so real filesystem paths
* that happen to contain `#` are never misrouted to the SQLite parser.
* @param candidatePath - The synthetic path to parse.
* @returns `{ dbPath, sessionId }` if the path is a valid synthetic candidate, `null` otherwise.
*/
export function splitOpenCodeSqliteCandidate(
candidatePath: string
): { dbPath: string; sessionId: string } | null {
const separatorIndex = candidatePath.lastIndexOf(OPENCODE_SQLITE_PATH_SEPARATOR)
if (separatorIndex <= 0 || separatorIndex === candidatePath.length - 1) {
return null
}
const dbPath = candidatePath.slice(0, separatorIndex)
const sessionId = candidatePath.slice(separatorIndex + 1)
if (!dbPath || !sessionId) {
return null
}
// Why: OpenCode DB files are named opencode*.db; reject anything else so we
// never misroute a real filesystem path that happens to contain '#'.
if (!/^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/i.test(basename(dbPath))) {
return null
}
return { dbPath, sessionId }
}
/**
* Type guard: returns `true` if the path is a valid synthetic OpenCode SQLite
* candidate path (i.e. `splitOpenCodeSqliteCandidate` would return non-null).
* @param candidatePath - The path to test.
* @returns `true` if the path is a synthetic SQLite candidate, `false` otherwise.
*/
export function looksLikeOpenCodeSqliteCandidate(candidatePath: string): boolean {
return splitOpenCodeSqliteCandidate(candidatePath) !== null
}
@@ -0,0 +1,499 @@
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import Database from '../sqlite/sync-database'
import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths'
import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-discovery'
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
let tempDirs: string[] = []
afterEach(() => {
for (const dir of tempDirs) {
rmSync(dir, { recursive: true, force: true })
}
tempDirs = []
})
function createTempDb(): { db: Database.Database; path: string } {
const dir = mkdtempSync(join(tmpdir(), 'orca-opencode-sqlite-'))
tempDirs.push(dir)
const path = join(dir, 'opencode.db')
return { db: new Database(path), path }
}
function applyOpenCodeSchema(db: Database.Database): void {
db.exec(`
CREATE TABLE session (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
parent_id TEXT,
slug TEXT NOT NULL,
directory TEXT NOT NULL,
title TEXT NOT NULL,
version TEXT NOT NULL,
share_url TEXT,
summary_additions INTEGER,
summary_deletions INTEGER,
summary_files INTEGER,
summary_diffs TEXT,
revert TEXT,
permission TEXT,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
time_compacting INTEGER,
time_archived INTEGER,
workspace_id TEXT,
path TEXT,
agent TEXT,
model TEXT,
cost REAL DEFAULT 0 NOT NULL,
tokens_input INTEGER DEFAULT 0 NOT NULL,
tokens_output INTEGER DEFAULT 0 NOT NULL,
tokens_reasoning INTEGER DEFAULT 0 NOT NULL,
tokens_cache_read INTEGER DEFAULT 0 NOT NULL,
tokens_cache_write INTEGER DEFAULT 0 NOT NULL,
metadata TEXT
);
CREATE TABLE message (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE part (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL,
session_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE project (
id TEXT PRIMARY KEY,
worktree TEXT NOT NULL,
vcs TEXT,
name TEXT,
icon_url TEXT,
icon_color TEXT,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
time_initialized INTEGER,
sandboxes TEXT NOT NULL,
commands TEXT,
icon_url_override TEXT
);
`)
}
function applyMinimalOpenCodeSchema(db: Database.Database): void {
db.exec(`CREATE TABLE session (
id TEXT PRIMARY KEY,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL
);`)
}
function insertSession(
db: Database.Database,
args: {
id: string
title?: string
directory?: string
timeCreated: number
timeUpdated: number
parentId?: string | null
timeArchived?: number | null
model?: string | null
agent?: string | null
tokensInput?: number
tokensOutput?: number
tokensReasoning?: number
tokensCacheRead?: number
cost?: number
}
): void {
db.prepare(
`INSERT INTO session (id, project_id, parent_id, slug, directory, title, version,
time_created, time_updated, time_archived, model, agent, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write)
VALUES (?, 'proj-1', ?, ?, ?, ?, '1.0.0',
?, ?, ?, ?, ?, ?,
?, ?, ?, ?, 0)`
).run(
args.id,
args.parentId ?? null,
`slug-${args.id}`,
args.directory ?? '/tmp/opencode',
args.title ?? 'OpenCode title',
args.timeCreated,
args.timeUpdated,
args.timeArchived ?? null,
args.model ?? JSON.stringify({ id: 'glm-5.2', providerID: 'zai-coding-plan' }),
args.agent ?? 'build',
args.cost ?? 0,
args.tokensInput ?? 100,
args.tokensOutput ?? 40,
args.tokensReasoning ?? 10,
args.tokensCacheRead ?? 5
)
}
function insertMessage(
db: Database.Database,
args: {
id: string
sessionId: string
role: 'user' | 'assistant'
timeCreated: number
summaryTitle?: string | null
summaryBody?: string | null
}
): void {
const data = JSON.stringify({
role: args.role,
time: { created: args.timeCreated },
agent: 'build',
summary:
args.summaryTitle || args.summaryBody
? { title: args.summaryTitle ?? null, body: args.summaryBody ?? null }
: undefined
})
db.prepare(
`INSERT INTO message (id, session_id, time_created, time_updated, data)
VALUES (?, ?, ?, ?, ?)`
).run(args.id, args.sessionId, args.timeCreated, args.timeCreated, data)
}
function insertPart(
db: Database.Database,
args: {
id: string
messageId: string
sessionId: string
timeCreated: number
type?: 'text' | 'tool' | 'reasoning'
text?: string
}
): void {
const data = JSON.stringify({
type: args.type ?? 'text',
text: args.text ?? 'hello world'
})
db.prepare(
`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data)
VALUES (?, ?, ?, ?, ?, ?)`
).run(args.id, args.messageId, args.sessionId, args.timeCreated, args.timeCreated, data)
}
describe('listOpenCodeSqliteSessions', () => {
it('returns candidates sorted by time_updated desc via the synthesized mtimeMs', async () => {
const { db, path } = createTempDb()
applyOpenCodeSchema(db)
insertSession(db, {
id: 'ses_old',
title: 'Old',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_001_000
})
insertSession(db, {
id: 'ses_new',
title: 'New',
timeCreated: 1_777_634_002_000,
timeUpdated: 1_777_634_003_000
})
db.close()
const issues: AiVaultScanIssue[] = []
const candidates = await listOpenCodeSqliteSessions({
dbPaths: [path],
limit: 10,
issues
})
expect(issues).toEqual([])
expect(candidates).toHaveLength(2)
expect(candidates[0].agent).toBe('opencode')
expect(candidates[0].file.mtimeMs).toBe(1_777_634_003_000)
expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_new'))
expect(candidates[1].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_old'))
})
it('dedups matching session ids across databases and keeps the newest row', async () => {
const { db: oldDb, path: oldPath } = createTempDb()
applyOpenCodeSchema(oldDb)
insertSession(oldDb, {
id: 'ses_duplicate',
title: 'Old duplicate',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_001_000
})
oldDb.close()
const { db: newDb, path: newPath } = createTempDb()
applyOpenCodeSchema(newDb)
insertSession(newDb, {
id: 'ses_duplicate',
title: 'New duplicate',
timeCreated: 1_777_634_002_000,
timeUpdated: 1_777_634_003_000
})
newDb.close()
const candidates = await listOpenCodeSqliteSessions({
dbPaths: [oldPath, newPath],
limit: 10,
issues: []
})
expect(candidates).toHaveLength(1)
expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(newPath, 'ses_duplicate'))
})
it('excludes archived and child sessions', async () => {
const { db, path } = createTempDb()
applyOpenCodeSchema(db)
insertSession(db, {
id: 'ses_normal',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_001_000
})
insertSession(db, {
id: 'ses_archived',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_002_000,
timeArchived: 1_777_634_002_500
})
insertSession(db, {
id: 'ses_child',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_003_000,
parentId: 'ses_normal'
})
db.close()
const candidates = await listOpenCodeSqliteSessions({
dbPaths: [path],
limit: 10,
issues: []
})
expect(candidates.map((c) => c.file.path)).toEqual([
buildOpenCodeSqliteCandidatePath(path, 'ses_normal')
])
})
it('returns [] when the session table is missing (legacy install)', async () => {
const { db, path } = createTempDb()
db.exec('CREATE TABLE other (id TEXT)')
db.close()
const candidates = await listOpenCodeSqliteSessions({
dbPaths: [path],
limit: 10,
issues: []
})
expect(candidates).toEqual([])
})
it('records an issue when the DB file does not exist', async () => {
const issues: AiVaultScanIssue[] = []
const candidates = await listOpenCodeSqliteSessions({
dbPaths: ['/nonexistent/opencode.db'],
limit: 10,
issues
})
expect(candidates).toEqual([])
expect(issues).toHaveLength(1)
expect(issues[0].agent).toBe('opencode')
expect(issues[0].path).toBe('/nonexistent/opencode.db')
})
it('lists sessions from a minimal readable session table', async () => {
const { db, path } = createTempDb()
applyMinimalOpenCodeSchema(db)
db.prepare(`INSERT INTO session VALUES ('ses_minimal', 1777634000000, 1777634001000)`).run()
db.close()
const issues: AiVaultScanIssue[] = []
const candidates = await listOpenCodeSqliteSessions({
dbPaths: [path],
limit: 10,
issues
})
expect(issues).toEqual([])
expect(candidates).toHaveLength(1)
expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_minimal'))
})
})
describe('parseOpenCodeSqliteSession', () => {
it('builds an AiVaultSession with title, cwd, model, tokens, and resume command', async () => {
const { db, path } = createTempDb()
applyOpenCodeSchema(db)
insertSession(db, {
id: 'ses_1',
title: 'OpenCode title',
directory: '/tmp/opencode',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_001_000,
tokensInput: 100,
tokensOutput: 40,
tokensReasoning: 10,
tokensCacheRead: 5,
cost: 0.01
})
insertMessage(db, {
id: 'msg_1',
sessionId: 'ses_1',
role: 'user',
timeCreated: 1_777_634_000_500,
summaryTitle: 'OpenCode title'
})
insertPart(db, {
id: 'prt_1',
messageId: 'msg_1',
sessionId: 'ses_1',
timeCreated: 1_777_634_000_500,
text: 'Plan the work'
})
insertMessage(db, {
id: 'msg_2',
sessionId: 'ses_1',
role: 'assistant',
timeCreated: 1_777_634_000_900
})
insertPart(db, {
id: 'prt_2',
messageId: 'msg_2',
sessionId: 'ses_1',
timeCreated: 1_777_634_001_000,
text: 'Done'
})
db.close()
const session = await parseOpenCodeSqliteSession({
dbPath: path,
sessionId: 'ses_1',
platform: 'darwin'
})
expect(session).not.toBeNull()
expect(session!.agent).toBe('opencode')
expect(session!.sessionId).toBe('ses_1')
expect(session!.filePath).toBe(path)
expect(session!.title).toBe('OpenCode title')
expect(session!.cwd).toBe('/tmp/opencode')
expect(session!.model).toBe('glm-5.2')
expect(session!.totalTokens).toBe(150)
expect(session!.messageCount).toBe(2)
expect(session!.createdAt).toBe(new Date(1_777_634_000_000).toISOString())
expect(session!.updatedAt).toBe(new Date(1_777_634_001_000).toISOString())
expect(session!.resumeCommand).toBe("cd '/tmp/opencode' && opencode --session 'ses_1'")
expect(session!.previewMessages).toHaveLength(2)
expect(session!.previewMessages[0].text).toBe('Plan the work')
expect(session!.previewMessages[0].role).toBe('user')
expect(session!.previewMessages[1].text).toBe('Done')
expect(session!.previewMessages[1].role).toBe('assistant')
})
it('falls back to summary.body for title when session.title is empty', async () => {
const { db, path } = createTempDb()
applyOpenCodeSchema(db)
insertSession(db, {
id: 'ses_2',
title: '',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_001_000
})
insertMessage(db, {
id: 'msg_1',
sessionId: 'ses_2',
role: 'user',
timeCreated: 1_777_634_000_500,
summaryBody: 'fallback title from summary'
})
insertPart(db, {
id: 'prt_1',
messageId: 'msg_1',
sessionId: 'ses_2',
timeCreated: 1_777_634_000_500,
text: 'hello'
})
db.close()
const session = await parseOpenCodeSqliteSession({
dbPath: path,
sessionId: 'ses_2',
platform: 'darwin'
})
expect(session).not.toBeNull()
expect(session!.title).toBe('fallback title from summary')
})
it('returns null when the session id is not found', async () => {
const { db, path } = createTempDb()
applyOpenCodeSchema(db)
insertSession(db, {
id: 'ses_real',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_001_000
})
db.close()
const session = await parseOpenCodeSqliteSession({
dbPath: path,
sessionId: 'ses_missing',
platform: 'darwin'
})
expect(session).toBeNull()
})
it('returns null when the DB has no session table', async () => {
const { db, path } = createTempDb()
db.exec('CREATE TABLE other (id TEXT)')
db.close()
const session = await parseOpenCodeSqliteSession({
dbPath: path,
sessionId: 'ses_1',
platform: 'darwin'
})
expect(session).toBeNull()
})
it('parses a minimal readable session table without optional columns or messages', async () => {
const { db, path } = createTempDb()
applyMinimalOpenCodeSchema(db)
db.prepare(`INSERT INTO session VALUES ('ses_minimal', 1777634000000, 1777634001000)`).run()
db.close()
const session = await parseOpenCodeSqliteSession({
dbPath: path,
sessionId: 'ses_minimal',
platform: 'darwin'
})
expect(session).not.toBeNull()
expect(session!.sessionId).toBe('ses_minimal')
expect(session!.filePath).toBe(path)
expect(session!.title).toBe('OpenCode ses_mini')
expect(session!.cwd).toBeNull()
expect(session!.model).toBeNull()
expect(session!.messageCount).toBe(0)
expect(session!.totalTokens).toBe(0)
expect(session!.previewMessages).toEqual([])
})
it('extracts model from older modelID schema', async () => {
const { db, path } = createTempDb()
applyOpenCodeSchema(db)
insertSession(db, {
id: 'ses_3',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_001_000,
model: JSON.stringify({ modelID: 'claude-sonnet-4-5' })
})
db.close()
const session = await parseOpenCodeSqliteSession({
dbPath: path,
sessionId: 'ses_3',
platform: 'darwin'
})
expect(session).not.toBeNull()
expect(session!.model).toBe('claude-sonnet-4-5')
})
})
@@ -0,0 +1,263 @@
import type { AiVaultSession, AiVaultSessionPreviewMessage } from '../../shared/ai-vault-types'
import {
addPreviewMessage,
createAccumulator,
finalizeSession,
updateTimeline
} from './session-scanner-accumulator'
import { normalizeTitleText } from './session-scanner-values'
import SyncDatabase from '../sqlite/sync-database'
import { columnExists, tableExists } from '../opencode-usage/schema-helpers'
// Why: OpenCode 1.17.x migrated session storage from per-session JSON files
// to a single SQLite DB at ~/.local/share/opencode/opencode.db. This module
// parses individual sessions from the DB into AiVaultSession objects. The
// discovery layer (listing candidates) lives in
// session-scanner-opencode-sqlite-discovery.ts.
const OPENCODE_SQLITE_PREVIEW_LIMIT = 5
type SessionRow = {
id: string
title: string | null
directory: string | null
time_created: number
time_updated: number
model_json: string | null
agent: string | null
tokens_input: number
tokens_output: number
tokens_reasoning: number
tokens_cache_read: number
cost: number
message_count: number
}
type PreviewRow = {
role: string | null
part_data: string
time_created: number
summary_title: string | null
summary_body: string | null
}
function openReadonlyDatabase(dbPath: string): SyncDatabase {
const db = new SyncDatabase(dbPath, { readonly: true, fileMustExist: true })
// Why: belt-and-suspenders guard so a bug in the SELECT list can never
// mutate the user's opencode.db.
db.pragma('query_only = ON')
return db
}
function canReadOpenCodeSessions(db: SyncDatabase): boolean {
return (
tableExists(db, 'session') &&
columnExists(db, 'session', 'time_created') &&
columnExists(db, 'session', 'time_updated')
)
}
function sessionColumnSelect(db: SyncDatabase, columnName: string): string {
return columnExists(db, 'session', columnName) ? `s.${columnName}` : 'NULL'
}
function sessionNumberColumnSelect(db: SyncDatabase, columnName: string): string {
return columnExists(db, 'session', columnName) ? `s.${columnName}` : '0'
}
function canCountOpenCodeMessages(db: SyncDatabase): boolean {
return (
tableExists(db, 'message') &&
columnExists(db, 'message', 'session_id') &&
columnExists(db, 'message', 'data')
)
}
function buildSessionQuery(db: SyncDatabase): string {
const messageCountSubquery = canCountOpenCodeMessages(db)
? `(SELECT COUNT(*) FROM message m
WHERE m.session_id = s.id
AND json_extract(m.data, '$.role') IN ('user','assistant'))`
: '0'
return `SELECT s.id,
${sessionColumnSelect(db, 'title')} AS title,
${sessionColumnSelect(db, 'directory')} AS directory,
s.time_created,
s.time_updated,
${sessionColumnSelect(db, 'model')} AS model_json,
${sessionColumnSelect(db, 'agent')} AS agent,
${sessionNumberColumnSelect(db, 'tokens_input')} AS tokens_input,
${sessionNumberColumnSelect(db, 'tokens_output')} AS tokens_output,
${sessionNumberColumnSelect(db, 'tokens_reasoning')} AS tokens_reasoning,
${sessionNumberColumnSelect(db, 'tokens_cache_read')} AS tokens_cache_read,
${sessionNumberColumnSelect(db, 'cost')} AS cost,
${messageCountSubquery} AS message_count
FROM session s
WHERE s.id = ?
LIMIT 1`
}
function extractModelId(modelJson: string | null): string | null {
if (!modelJson) {
return null
}
try {
const parsed = JSON.parse(modelJson) as unknown
const record =
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null
if (!record) {
return null
}
// Why: OpenCode 1.17.x stores model as {"id":"glm-5.2","providerID":"..."}.
// Older schemas used {"modelID":"..."}; accept both.
return (
(typeof record.id === 'string' && record.id.trim()) ||
(typeof record.modelID === 'string' && record.modelID.trim()) ||
null
)
} catch {
return null
}
}
function mapPreviewRole(role: string | null): AiVaultSessionPreviewMessage['role'] {
if (role === 'user' || role === 'assistant' || role === 'system' || role === 'tool') {
return role
}
return 'unknown'
}
function extractPartText(partData: string): string | null {
try {
const parsed = JSON.parse(partData) as unknown
const record =
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null
if (!record) {
return null
}
if (typeof record.text === 'string') {
return record.text
}
return null
} catch {
return null
}
}
function buildPreviewQuery(db: SyncDatabase): string | null {
if (
!canCountOpenCodeMessages(db) ||
!tableExists(db, 'part') ||
!columnExists(db, 'message', 'id') ||
!columnExists(db, 'part', 'message_id') ||
!columnExists(db, 'part', 'time_created') ||
!columnExists(db, 'part', 'data')
) {
return null
}
return `SELECT json_extract(m.data, '$.role') AS role,
p.data AS part_data,
p.time_created,
json_extract(m.data, '$.summary.title') AS summary_title,
json_extract(m.data, '$.summary.body') AS summary_body
FROM message m
JOIN part p ON p.message_id = m.id
WHERE m.session_id = ?
AND json_extract(m.data, '$.role') IN ('user','assistant')
AND json_extract(p.data, '$.type') = 'text'
ORDER BY p.time_created DESC
LIMIT ?`
}
/**
* Parse a single OpenCode session from the SQLite database into an
* `AiVaultSession`. Reads session metadata (title, cwd, model, tokens, cost)
* and up to 5 preview messages by joining the `message` and `part` tables.
* The database is opened read-only with `PRAGMA query_only = ON` as a
* belt-and-suspenders guard against mutations.
* @param args.dbPath - Absolute path to the opencode.db file.
* @param args.sessionId - The session ID (primary key in the `session` table).
* @param args.platform - The platform to use for resume command generation.
* @returns The parsed `AiVaultSession`, or `null` if the session does not exist
* or the database lacks the required schema.
*/
export async function parseOpenCodeSqliteSession(args: {
dbPath: string
sessionId: string
platform: NodeJS.Platform
}): Promise<AiVaultSession | null> {
const { dbPath, sessionId, platform } = args
let db: SyncDatabase | null = null
try {
db = openReadonlyDatabase(dbPath)
if (!canReadOpenCodeSessions(db)) {
return null
}
const row = db.prepare(buildSessionQuery(db)).get(sessionId) as SessionRow | undefined
if (!row || row.id !== sessionId) {
return null
}
const mtimeMs =
typeof row.time_updated === 'number' && row.time_updated > 0
? row.time_updated
: row.time_created
// Why: discovery uses a synthetic db#session path only for parser routing.
// The UI's log open/reveal actions need a real filesystem path.
const accumulator = createAccumulator({
agent: 'opencode',
file: {
path: dbPath,
mtimeMs,
modifiedAt: new Date(mtimeMs).toISOString()
},
sessionId
})
accumulator.title = normalizeTitleText(row.title ?? '')
accumulator.cwd = row.directory
accumulator.model = extractModelId(row.model_json)
accumulator.totalTokens =
(row.tokens_input ?? 0) + (row.tokens_output ?? 0) + (row.tokens_reasoning ?? 0)
accumulator.messageCount = row.message_count ?? 0
updateTimeline(accumulator, row.time_created)
updateTimeline(accumulator, row.time_updated)
const previewSql = buildPreviewQuery(db)
if (previewSql) {
const previewRows = db
.prepare(previewSql)
.all(sessionId, OPENCODE_SQLITE_PREVIEW_LIMIT) as PreviewRow[]
// Why: query returns newest-first; push in chronological order so the
// accumulator's ring buffer keeps the newest OPENCODE_SQLITE_PREVIEW_LIMIT
// messages.
for (let i = previewRows.length - 1; i >= 0; i--) {
const previewRow = previewRows[i]
if (!previewRow) {
continue
}
const text = extractPartText(previewRow.part_data)
if (!text) {
continue
}
addPreviewMessage(accumulator, {
role: mapPreviewRole(previewRow.role),
text,
timestamp: previewRow.time_created
})
if (previewRow.role === 'user' && !accumulator.title) {
accumulator.title =
normalizeTitleText(previewRow.summary_title ?? '') ||
normalizeTitleText(previewRow.summary_body ?? '')
}
}
}
return finalizeSession(accumulator, platform)
} finally {
db?.close()
}
}
@@ -2,7 +2,7 @@ import { createReadStream } from 'fs'
import { readFile } from 'fs/promises'
import { createInterface } from 'readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { CodexUsageSnapshot, FileWithMtime, SessionAccumulator } from './session-scanner-types'
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
import {
addPreviewContent,
createAccumulator,
@@ -16,14 +16,10 @@ import {
asRecord,
claudeUsageTotal,
extractContentText,
extractGitBranch,
extractMessageText,
extractModel,
extractString,
normalizeCodexUsage,
normalizeTitleText,
parseJsonObject,
subtractCodexUsage,
tokenTotal
} from './session-scanner-values'
@@ -99,139 +95,6 @@ export async function parseClaudeSessionFile(
return finalizeSession(accumulator, platform)
}
export async function parseCodexSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform,
codexHome: string | null = null
): Promise<AiVaultSession | null> {
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 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)
const delta = totalUsage ? subtractCodexUsage(totalUsage, previousTotals) : lastUsage
if (totalUsage) {
previousTotals = totalUsage
}
if (delta) {
accumulator.totalTokens += delta.totalTokens
}
const model = extractModel(payload)
if (model) {
accumulator.model = model
}
}
return finalizeSession(accumulator, platform, { codexHome })
}
function extractCodexThreadSource(payload: Record<string, unknown>): string | null {
return extractString(payload.thread_source) ?? extractString(payload.threadSource)
}
function isCodexWorkerSession(payload: Record<string, unknown>): boolean {
const threadSource = extractCodexThreadSource(payload)
if (threadSource) {
return threadSource.toLowerCase() !== 'user'
}
const source = asRecord(payload.source)
return Boolean(asRecord(source?.subagent))
}
export async function parseGeminiSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
@@ -4,6 +4,7 @@ import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { uniqueCodexSessionsDirs } from './session-scanner-codex-paths'
import { discoverFiles, discoverOpenClawFiles } from './session-scanner-discovery'
import { droidDiscoveries, kimiDiscoveries } from './session-scanner-droid-kimi-sources'
import { opencodeDiscoveries } from './session-scanner-opencode-sources'
import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types'
import { normalizePiSessionsDir } from './session-scanner-values'
@@ -17,10 +18,6 @@ const COPILOT_SESSIONS_DIR = join(
'session-state'
)
const CURSOR_PROJECTS_DIR = join(homedir(), '.cursor', 'projects')
const OPENCODE_STORAGE_DIR = join(
process.env.OPENCODE_CONFIG_DIR?.trim() || join(homedir(), '.local', 'share', 'opencode'),
'storage'
)
const GROK_SESSIONS_DIR = join(
process.env.GROK_HOME?.trim() || join(homedir(), '.grok'),
'sessions'
@@ -55,7 +52,11 @@ export async function discoverAiVaultSessionSources(args: {
...(options.additionalCodexSessionsDirs ?? [])
])
return Promise.all<SessionFileDiscovery>([
return Promise.all([
// Why: OpenCode 1.17.x migrated sessions from per-session JSON files to a
// SQLite DB. discoverOpenCodeSessions runs both the file scanner (legacy)
// and the SQLite scanner (1.17.x); dedup by sessionId happens inside.
...opencodeDiscoveries(options, wslHomeDirs, limitPerAgent, issues),
...claudeDiscoveries(options, wslHomeDirs, limitPerAgent, issues),
...codexDiscoveries(codexSessionsDirs, limitPerAgent, issues),
...standardDiscoveries(options, wslHomeDirs, limitPerAgent, issues),
@@ -109,7 +110,6 @@ function standardDiscoveries(
discoverFiles({ rootDir, limit, agent: 'copilot', issues, extensions: ['.jsonl'] })
),
...cursorDiscoveries(options, wslHomeDirs, limit, issues),
...opencodeDiscoveries(options, wslHomeDirs, limit, issues),
...grokDiscoveries(options, wslHomeDirs, limit, issues),
...devinDiscoveries(options, wslHomeDirs, limit, issues),
...hermesDiscoveries(options, wslHomeDirs, limit, issues),
@@ -139,21 +139,6 @@ function cursorDiscoveries(
)
}
function opencodeDiscoveries(
options: AiVaultScanOptions,
wslHomeDirs: readonly string[],
limit: number,
issues: AiVaultScanIssue[]
): Promise<SessionFileDiscovery>[] {
return sessionRootDirs(
join(options.opencodeStorageDir ?? OPENCODE_STORAGE_DIR, 'session'),
wslHomeDirs,
['.local', 'share', 'opencode', 'storage', 'session']
).map((rootDir) =>
discoverFiles({ rootDir, limit, agent: 'opencode', issues, extensions: ['.json'] })
)
}
function grokDiscoveries(
options: AiVaultScanOptions,
wslHomeDirs: readonly string[],
@@ -5,7 +5,7 @@ const HIDDEN_BLOCK_CLOSE_SCAN_LIMIT = 256 * 1024
const HIDDEN_BLOCK_OPEN_TAG_SCAN_LIMIT = 512
const FIELD_BLANK_SCAN_LIMIT = 1024
const AGENTS_INSTRUCTIONS_PREFIX = '# AGENTS.md instructions for'
const AGENTS_INSTRUCTIONS_PREFIX = '# AGENTS.md instructions'
const XML_INSTRUCTIONS_PREFIX = '<INSTRUCTIONS>'
const HIDDEN_TEXT_BLOCKS = [
@@ -14,6 +14,9 @@ export type AiVaultScanOptions = {
copilotSessionsDir?: string
cursorProjectsDir?: string
opencodeStorageDir?: string
// Why: OpenCode 1.17.x stores sessions in SQLite; tests inject a temp DB
// here so they don't depend on the real ~/.local/share/opencode.
opencodeDbPaths?: readonly string[]
grokSessionsDir?: string
devinTranscriptsDir?: string
hermesSessionsDir?: string
+14 -2
View File
@@ -21,6 +21,9 @@ function isolatedScanRoots(root: string) {
copilotSessionsDir: join(root, 'copilot-sessions'),
cursorProjectsDir: join(root, 'cursor-projects'),
opencodeStorageDir: join(root, 'opencode-storage'),
// Why: prevent the SQLite scanner from picking up the real
// ~/.local/share/opencode/opencode.db during tests.
opencodeDbPaths: [] as readonly string[],
grokSessionsDir: join(root, 'grok-sessions'),
devinTranscriptsDir: join(root, 'devin-transcripts'),
hermesSessionsDir: join(root, 'hermes-sessions'),
@@ -110,7 +113,7 @@ describe('scanAiVaultSessions', () => {
type: 'message',
role: 'user',
content: [
{ type: 'text', text: '# AGENTS.md instructions for /repo/app <INSTRUCTIONS>' }
{ type: 'text', text: '# AGENTS.md instructions\n\n<INSTRUCTIONS>repo policy' }
]
}
}),
@@ -162,6 +165,15 @@ describe('scanAiVaultSessions', () => {
})
].join('\n')
)
await writeFile(
join(root, 'session_index.jsonl'),
jsonLines([
{
id: '019f0000-1111-7222-8333-444444444444',
thread_name: 'Indexed Codex resume picker title'
}
])
)
const result = await scanAiVaultSessions({
...roots,
@@ -171,7 +183,7 @@ describe('scanAiVaultSessions', () => {
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(2)
expect(result.sessions.map((session) => session.title).sort()).toEqual([
'Fix the resume picker filters',
'Indexed Codex resume picker title',
'Vault polish pass'
])
+9
View File
@@ -21,6 +21,15 @@ const DEFAULT_LIMIT = 1000
const DEFAULT_SCAN_LIMIT_PER_AGENT = 1000
const SESSION_PARSE_CONCURRENCY = 8
/**
* Scan all supported AI agent session stores and return a unified, sorted,
* deduplicated list of sessions for the AI Vault panel. Discovers sessions
* from file-based stores (Claude, Codex, Gemini, etc.) and SQLite-based
* stores (OpenCode 1.17.x). Results are sorted by session sort time DESC
* and truncated to `limit`.
* @param options - Optional scan configuration (limits, custom dirs, platform).
* @returns The list of sessions, scan issues, and a timestamp.
*/
export async function scanAiVaultSessions(
options: AiVaultScanOptions = {}
): Promise<AiVaultListResult> {
+374 -2
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
browserWindowGetAllWindowsMock,
@@ -36,11 +37,37 @@ vi.mock('../../resources/app-icons/orca-watercolor.png?asset', () => ({
default: 'watercolor-icon'
}))
vi.mock('../../resources/app-icons/orca-watercolor.png?asset&asarUnpack', () => ({
default: 'watercolor-icon-unpacked'
}))
vi.mock('../../resources/app-icons/orca-blue.png?asset', () => ({
default: 'blue-icon'
}))
import { applyAppIcon, getAppIconPath } from './app-icon'
vi.mock('../../resources/app-icons/orca-blue.png?asset&asarUnpack', () => ({
default: 'blue-icon-unpacked'
}))
import { applyAppIcon, getAppIconPath, persistMacDockIcon } from './app-icon'
function waitForQueuedPersistence(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve))
}
async function waitForQueuedPersistenceMicrotasks(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
}
function createMockChildProcess(): EventEmitter & { kill: ReturnType<typeof vi.fn> } {
const childProcess = new EventEmitter() as EventEmitter & { kill: ReturnType<typeof vi.fn> }
childProcess.kill = vi.fn(() => {
childProcess.emit('exit')
return true
})
return childProcess
}
describe('app icon selection', () => {
beforeEach(() => {
@@ -51,6 +78,10 @@ describe('app icon selection', () => {
isMock.dev = false
})
afterEach(() => {
vi.useRealTimers()
})
it('resolves classic, watercolor, blue, and invalid icon ids', () => {
expect(getAppIconPath('classic')).toBe('classic-icon')
expect(getAppIconPath('watercolor')).toBe('watercolor-icon')
@@ -76,4 +107,345 @@ describe('app icon selection', () => {
}
expect(windowSetIconMock).toHaveBeenCalledWith(image)
})
it('persists a custom macOS dock icon to the app bundle for inactive Dock pins', async () => {
const execFile = vi.fn(
(
_file: string,
_args: string[],
optionsOrCallback: unknown,
callback?: (error: Error | null) => void
) => {
const onComplete =
typeof optionsOrCallback === 'function'
? (optionsOrCallback as (error: Error | null) => void)
: callback
onComplete?.(null)
}
)
persistMacDockIcon('watercolor', {
appBundlePath: '/Applications/Orca.app',
execFile,
isDevApp: false,
platform: 'darwin'
})
await waitForQueuedPersistence()
expect(execFile).toHaveBeenCalledWith(
'/usr/bin/osascript',
expect.arrayContaining(['-e', expect.stringContaining('setIcon:image forFile:appPath')]),
expect.objectContaining({
env: expect.objectContaining({
ORCA_APP_BUNDLE_PATH: '/Applications/Orca.app',
ORCA_APP_ICON_PATH: 'watercolor-icon-unpacked'
})
}),
expect.any(Function)
)
})
it('clears the AppKit icon and Finder metadata when switching macOS back to classic', async () => {
const execFile = vi.fn(
(
_file: string,
_args: string[],
optionsOrCallback: unknown,
callback?: (error: Error | null) => void
) => {
const onComplete =
typeof optionsOrCallback === 'function'
? (optionsOrCallback as (error: Error | null) => void)
: callback
onComplete?.(null)
}
)
persistMacDockIcon('classic', {
appBundlePath: '/Applications/Orca.app',
execFile,
isDevApp: false,
platform: 'darwin'
})
await waitForQueuedPersistence()
expect(execFile).toHaveBeenNthCalledWith(
1,
'/usr/bin/osascript',
expect.arrayContaining([
'-e',
expect.stringContaining('setIcon:(missing value) forFile:appPath')
]),
expect.objectContaining({
env: expect.objectContaining({
ORCA_APP_BUNDLE_PATH: '/Applications/Orca.app'
}),
timeout: 10_000
}),
expect.any(Function)
)
expect(execFile).toHaveBeenCalledWith(
'/usr/bin/xattr',
['-d', 'com.apple.FinderInfo', '/Applications/Orca.app'],
expect.objectContaining({
timeout: 10_000
}),
expect.any(Function)
)
expect(execFile).toHaveBeenCalledWith(
'/usr/bin/xattr',
['-d', 'com.apple.ResourceFork', '/Applications/Orca.app'],
expect.objectContaining({
timeout: 10_000
}),
expect.any(Function)
)
})
it('warns for non-benign failures when clearing Finder custom icon metadata', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const execFile = vi.fn(
(
file: string,
args: string[],
optionsOrCallback: unknown,
callback?: (error: Error | null) => void
) => {
const onComplete =
typeof optionsOrCallback === 'function'
? (optionsOrCallback as (error: Error | null) => void)
: callback
if (file !== '/usr/bin/xattr') {
onComplete?.(null)
return
}
onComplete?.(new Error(args[1] === 'com.apple.FinderInfo' ? 'No such xattr' : 'EACCES'))
}
)
persistMacDockIcon('classic', {
appBundlePath: '/Applications/Orca.app',
execFile,
isDevApp: false,
platform: 'darwin'
})
await waitForQueuedPersistence()
expect(warnSpy).toHaveBeenCalledTimes(1)
expect(warnSpy).toHaveBeenCalledWith(
'[app-icon] failed to clear macOS dock icon metadata com.apple.ResourceFork:',
expect.any(Error)
)
warnSpy.mockRestore()
})
it('warns when the AppKit classic icon reset fails', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const execFile = vi.fn(
(
file: string,
_args: string[],
optionsOrCallback: unknown,
callback?: (error: Error | null) => void
) => {
const onComplete =
typeof optionsOrCallback === 'function'
? (optionsOrCallback as (error: Error | null) => void)
: callback
onComplete?.(file === '/usr/bin/osascript' ? new Error('reset denied') : null)
}
)
persistMacDockIcon('classic', {
appBundlePath: '/Applications/Orca.app',
execFile,
isDevApp: false,
platform: 'darwin'
})
await waitForQueuedPersistence()
expect(warnSpy).toHaveBeenCalledWith(
'[app-icon] failed to clear macOS dock icon:',
expect.any(Error)
)
warnSpy.mockRestore()
})
it('serializes rapid macOS dock icon persistence so the last icon request wins', async () => {
const pendingCallbacks: (() => void)[] = []
const execFile = vi.fn(
(
_file: string,
_args: string[],
optionsOrCallback: unknown,
callback?: (error: Error | null) => void
) => {
const onComplete =
typeof optionsOrCallback === 'function'
? (optionsOrCallback as (error: Error | null) => void)
: callback
pendingCallbacks.push(() => onComplete?.(null))
}
)
persistMacDockIcon('watercolor', {
appBundlePath: '/Applications/Orca.app',
execFile,
isDevApp: false,
platform: 'darwin'
})
await waitForQueuedPersistence()
persistMacDockIcon('blue', {
appBundlePath: '/Applications/Orca.app',
execFile,
isDevApp: false,
platform: 'darwin'
})
persistMacDockIcon('classic', {
appBundlePath: '/Applications/Orca.app',
execFile,
isDevApp: false,
platform: 'darwin'
})
expect(execFile).toHaveBeenCalledTimes(1)
expect(execFile).toHaveBeenCalledWith(
'/usr/bin/osascript',
expect.any(Array),
expect.objectContaining({
env: expect.objectContaining({
ORCA_APP_ICON_PATH: 'watercolor-icon-unpacked'
})
}),
expect.any(Function)
)
pendingCallbacks.shift()?.()
await waitForQueuedPersistence()
expect(execFile).toHaveBeenCalledTimes(2)
expect(execFile).not.toHaveBeenCalledWith(
'/usr/bin/osascript',
expect.any(Array),
expect.objectContaining({
env: expect.objectContaining({
ORCA_APP_ICON_PATH: 'blue-icon-unpacked'
})
}),
expect.any(Function)
)
expect(execFile).toHaveBeenNthCalledWith(
2,
'/usr/bin/osascript',
expect.arrayContaining([
'-e',
expect.stringContaining('setIcon:(missing value) forFile:appPath')
]),
expect.objectContaining({
env: expect.objectContaining({
ORCA_APP_BUNDLE_PATH: '/Applications/Orca.app'
}),
timeout: 10_000
}),
expect.any(Function)
)
pendingCallbacks.shift()?.()
await waitForQueuedPersistence()
expect(execFile).toHaveBeenCalledTimes(4)
expect(execFile).toHaveBeenNthCalledWith(
3,
'/usr/bin/xattr',
['-d', 'com.apple.FinderInfo', '/Applications/Orca.app'],
expect.objectContaining({
timeout: 10_000
}),
expect.any(Function)
)
expect(execFile).toHaveBeenNthCalledWith(
4,
'/usr/bin/xattr',
['-d', 'com.apple.ResourceFork', '/Applications/Orca.app'],
expect.objectContaining({
timeout: 10_000
}),
expect.any(Function)
)
for (const completeCommand of pendingCallbacks) {
completeCommand()
}
await waitForQueuedPersistence()
})
it('continues macOS dock icon persistence when a command never completes', async () => {
vi.useFakeTimers()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const hungChildProcess = createMockChildProcess()
const execFile = vi.fn(
(
_file: string,
_args: string[],
optionsOrCallback: unknown,
callback?: (error: Error | null) => void
) => {
if (execFile.mock.calls.length === 1) {
return hungChildProcess
}
const onComplete =
typeof optionsOrCallback === 'function'
? (optionsOrCallback as (error: Error | null) => void)
: callback
onComplete?.(null)
return undefined
}
)
persistMacDockIcon('watercolor', {
appBundlePath: '/Applications/Orca.app',
execFile,
isDevApp: false,
platform: 'darwin'
})
await waitForQueuedPersistenceMicrotasks()
persistMacDockIcon('blue', {
appBundlePath: '/Applications/Orca.app',
execFile,
isDevApp: false,
platform: 'darwin'
})
expect(execFile).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(10_000)
await waitForQueuedPersistenceMicrotasks()
expect(hungChildProcess.kill).not.toHaveBeenCalled()
expect(execFile).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1_000)
await waitForQueuedPersistenceMicrotasks()
expect(warnSpy).toHaveBeenCalledWith('[app-icon] timed out persisting macOS dock icon')
expect(hungChildProcess.kill).toHaveBeenCalledTimes(1)
expect(execFile).toHaveBeenCalledTimes(2)
expect(execFile).toHaveBeenNthCalledWith(
2,
'/usr/bin/osascript',
expect.any(Array),
expect.objectContaining({
env: expect.objectContaining({
ORCA_APP_ICON_PATH: 'blue-icon-unpacked'
}),
timeout: 10_000
}),
expect.any(Function)
)
warnSpy.mockRestore()
})
})
+268
View File
@@ -1,9 +1,17 @@
import {
execFile as execFileChildProcess,
type ChildProcess,
type ExecFileOptions
} from 'node:child_process'
import { dirname, resolve } from 'node:path'
import { app, BrowserWindow, nativeImage } from 'electron'
import { is } from '@electron-toolkit/utils'
import classicIcon from '../../resources/icon.png?asset'
import classicDevIcon from '../../resources/icon-dev.png?asset'
import watercolorIcon from '../../resources/app-icons/orca-watercolor.png?asset'
import watercolorMacDockIcon from '../../resources/app-icons/orca-watercolor.png?asset&asarUnpack'
import blueIcon from '../../resources/app-icons/orca-blue.png?asset'
import blueMacDockIcon from '../../resources/app-icons/orca-blue.png?asset&asarUnpack'
import { normalizeAppIconId, type AppIconId } from '../shared/app-icon'
const APP_ICON_PATHS = {
@@ -12,6 +20,59 @@ const APP_ICON_PATHS = {
blue: blueIcon
} satisfies Record<AppIconId, string>
const MAC_DOCK_ICON_PATHS = {
watercolor: watercolorMacDockIcon,
blue: blueMacDockIcon
} satisfies Record<Exclude<AppIconId, 'classic'>, string>
type ExecFile = (
file: string,
args: string[],
optionsOrCallback: ExecFileOptions | ((error: Error | null) => void),
callback?: (error: Error | null) => void
) => unknown
type MacDockIconChildProcess = Pick<ChildProcess, 'kill' | 'once'>
type PersistMacDockIconOptions = {
appBundlePath?: string
execFile?: ExecFile
isDevApp?: boolean
platform?: NodeJS.Platform
}
const MAC_DOCK_ICON_SCRIPT = [
'use framework "AppKit"',
'use scripting additions',
'set appPath to system attribute "ORCA_APP_BUNDLE_PATH"',
'set iconPath to system attribute "ORCA_APP_ICON_PATH"',
"set image to current application's NSImage's alloc()'s initWithContentsOfFile:iconPath",
'if image is missing value then error "Orca app icon image could not be loaded"',
"set ok to current application's NSWorkspace's sharedWorkspace()'s setIcon:image forFile:appPath options:0",
'if ok is false then error "Orca app icon could not be persisted"'
]
const MAC_DOCK_ICON_CLEAR_SCRIPT = [
'use framework "AppKit"',
'use scripting additions',
'set appPath to system attribute "ORCA_APP_BUNDLE_PATH"',
"set ok to current application's NSWorkspace's sharedWorkspace()'s setIcon:(missing value) forFile:appPath options:0",
'if ok is false then error "Orca app icon could not be cleared"'
]
const MAC_DOCK_ICON_COMMAND_TIMEOUT_MS = 10_000
const MAC_DOCK_ICON_COMMAND_FALLBACK_MS = 1_000
const defaultExecFile: ExecFile = (file, args, optionsOrCallback, callback) => {
if (typeof optionsOrCallback === 'function') {
return execFileChildProcess(file, args, optionsOrCallback)
}
return execFileChildProcess(file, args, optionsOrCallback, callback ?? (() => {}))
}
let macDockIconPersistenceGeneration = 0
let macDockIconPersistenceQueue = Promise.resolve()
export function getAppIconPath(value: unknown): string {
return APP_ICON_PATHS[normalizeAppIconId(value)]
}
@@ -20,6 +81,212 @@ export function createAppIconImage(value: unknown): Electron.NativeImage {
return nativeImage.createFromPath(getAppIconPath(value))
}
function getMacAppBundlePath(): string | undefined {
const appBundlePath = resolve(dirname(app.getPath('exe')), '..', '..')
return appBundlePath.endsWith('.app') ? appBundlePath : undefined
}
function runMacCustomIconCommand(
execFile: ExecFile,
appBundlePath: string,
iconPath: string
): Promise<void> {
return runBoundedMacDockIconCommand({
args: MAC_DOCK_ICON_SCRIPT.flatMap((line) => ['-e', line]),
execFile,
file: '/usr/bin/osascript',
onError: (error) => {
console.warn('[app-icon] failed to persist macOS dock icon:', error)
},
options: {
env: {
...process.env,
ORCA_APP_BUNDLE_PATH: appBundlePath,
ORCA_APP_ICON_PATH: iconPath
}
},
timeoutWarning: '[app-icon] timed out persisting macOS dock icon'
})
}
type BoundedMacDockIconCommandOptions = {
args: string[]
execFile: ExecFile
file: string
onError: (error: Error) => void
options?: ExecFileOptions
timeoutWarning: string
}
function toError(value: unknown): Error {
return value instanceof Error ? value : new Error(String(value))
}
function isMacDockIconChildProcess(value: unknown): value is MacDockIconChildProcess {
return (
typeof value === 'object' &&
value !== null &&
'kill' in value &&
typeof value.kill === 'function' &&
'once' in value &&
typeof value.once === 'function'
)
}
function runBoundedMacDockIconCommand({
args,
execFile,
file,
onError,
options,
timeoutWarning
}: BoundedMacDockIconCommandOptions): Promise<void> {
return new Promise((resolve) => {
let childProcess: MacDockIconChildProcess | undefined
let settled = false
let forceFallback: NodeJS.Timeout | undefined
const finish = (error: Error | null): void => {
if (settled) {
return
}
settled = true
clearTimeout(fallback)
if (forceFallback) {
clearTimeout(forceFallback)
}
if (error) {
onError(error)
}
resolve()
}
const finishAfterFallback = (): void => {
if (settled) {
return
}
settled = true
if (forceFallback) {
clearTimeout(forceFallback)
}
console.warn(timeoutWarning)
resolve()
}
const fallback = setTimeout(() => {
if (settled) {
return
}
if (!childProcess) {
finishAfterFallback()
return
}
// Why: the queue must not release while an older icon process can still win.
childProcess.once('exit', finishAfterFallback)
childProcess.once('close', finishAfterFallback)
forceFallback = setTimeout(finishAfterFallback, MAC_DOCK_ICON_COMMAND_FALLBACK_MS)
childProcess.kill()
}, MAC_DOCK_ICON_COMMAND_TIMEOUT_MS + MAC_DOCK_ICON_COMMAND_FALLBACK_MS)
try {
const maybeChildProcess = execFile(
file,
args,
{
...options,
timeout: MAC_DOCK_ICON_COMMAND_TIMEOUT_MS
},
finish
)
if (isMacDockIconChildProcess(maybeChildProcess)) {
childProcess = maybeChildProcess
}
} catch (error) {
finish(toError(error))
}
})
}
function handleMacDockIconQueueError(error: unknown): void {
console.warn('[app-icon] failed to persist macOS dock icon:', error)
}
function enqueueMacDockIconPersistence(work: () => Promise<void>): void {
macDockIconPersistenceQueue = macDockIconPersistenceQueue
.catch(handleMacDockIconQueueError)
.then(work)
.catch(handleMacDockIconQueueError)
}
function clearMacCustomIconMetadata(execFile: ExecFile, appBundlePath: string): Promise<void> {
const clearAppKitIcon = (): Promise<void> => {
return runBoundedMacDockIconCommand({
args: MAC_DOCK_ICON_CLEAR_SCRIPT.flatMap((line) => ['-e', line]),
execFile,
file: '/usr/bin/osascript',
onError: (error) => {
console.warn('[app-icon] failed to clear macOS dock icon:', error)
},
options: {
env: {
...process.env,
ORCA_APP_BUNDLE_PATH: appBundlePath
}
},
timeoutWarning: '[app-icon] timed out clearing macOS dock icon'
})
}
const clearAttribute = (attribute: string): Promise<void> => {
return runBoundedMacDockIconCommand({
args: ['-d', attribute, appBundlePath],
execFile,
file: '/usr/bin/xattr',
onError: (error) => {
if (!error.message.includes('No such xattr')) {
console.warn(`[app-icon] failed to clear macOS dock icon metadata ${attribute}:`, error)
}
},
timeoutWarning: `[app-icon] timed out clearing macOS dock icon metadata ${attribute}`
})
}
return clearAppKitIcon().then(() =>
Promise.all([
clearAttribute('com.apple.FinderInfo'),
clearAttribute('com.apple.ResourceFork')
]).then(() => {})
)
}
export function persistMacDockIcon(value: unknown, options: PersistMacDockIconOptions = {}): void {
const platform = options.platform ?? process.platform
const isDevApp = options.isDevApp ?? (is.dev || !app.isPackaged)
if (platform !== 'darwin' || isDevApp) {
return
}
const appBundlePath = options.appBundlePath ?? getMacAppBundlePath()
if (!appBundlePath) {
return
}
const execFile = options.execFile ?? defaultExecFile
const iconId = normalizeAppIconId(value)
const generation = ++macDockIconPersistenceGeneration
enqueueMacDockIconPersistence(async () => {
// Why: stale queued writes must not reapply an older Dock pin icon.
if (generation !== macDockIconPersistenceGeneration) {
return
}
if (iconId === 'classic') {
await clearMacCustomIconMetadata(execFile, appBundlePath)
return
}
// Why: a stopped app's Dock tile is resolved from Finder metadata, not
// Electron's live app.dock.setIcon state.
await runMacCustomIconCommand(execFile, appBundlePath, MAC_DOCK_ICON_PATHS[iconId])
})
}
export function applyAppIcon(value: unknown): void {
const image = createAppIconImage(value)
if (image.isEmpty()) {
@@ -33,4 +300,5 @@ export function applyAppIcon(value: unknown): void {
window.setIcon(image)
}
}
persistMacDockIcon(value)
}
+48 -3
View File
@@ -161,14 +161,22 @@ describe('ClaudeHookService.install', () => {
)
expect(legacyCommands).toContain('/usr/local/bin/user-hook')
expect(
legacyCommands.some((command: string) => command.includes(CLAUDE_SCRIPT_FILE_NAME))
legacyCommands.some((command: string) =>
process.platform === 'win32'
? command.startsWith('powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ')
: command.includes(CLAUDE_SCRIPT_FILE_NAME)
)
).toBe(true)
expect(
legacyCommands.some((command: string) =>
command.includes('/Users/old/.orca/agent-hooks/claude-hook.sh')
)
).toBe(false)
expect(legacy.hooks.StopFailure[0].hooks[0].command).toContain(CLAUDE_SCRIPT_FILE_NAME)
expect(legacy.hooks.StopFailure[0].hooks[0].command).toMatch(
process.platform === 'win32'
? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
: new RegExp(CLAUDE_SCRIPT_FILE_NAME)
)
expect(
readFileSync(join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME), 'utf-8')
).toContain('DEVIN_PROJECT_DIR')
@@ -177,6 +185,36 @@ describe('ClaudeHookService.install', () => {
rmSync(tmpHome, { recursive: true, force: true })
}
})
// Why: #6078 — Claude Code runs hooks through Git Bash, and an unquoted path
// with a space (e.g. `C:/Users/Jane Doe`) splits at the space. The managed
// command must use an encoded launcher so Git Bash/cmd.exe never splits or
// expands the raw path before invoking the managed .cmd.
it.skipIf(process.platform !== 'win32')(
'wraps the managed hook command to survive spaces in the profile path (#6078)',
() => {
const tmpHome = mkdtempSync(join(tmpdir(), 'orca claude home with spaces '))
vi.stubEnv('HOME', tmpHome)
vi.stubEnv('USERPROFILE', tmpHome)
try {
expect(new ClaudeHookService().install().state).toBe('installed')
const settings = JSON.parse(
readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8')
) as { hooks: Record<string, { hooks: { command: string }[] }[]> }
for (const eventName of ['UserPromptSubmit', 'Stop', 'StopFailure']) {
const command = settings.hooks[eventName]?.[0]?.hooks?.[0]?.command
expect(command).toMatch(
/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
)
}
} finally {
vi.unstubAllEnvs()
rmSync(tmpHome, { recursive: true, force: true })
}
}
)
})
describe('ClaudeHookService.installRemote', () => {
@@ -287,7 +325,14 @@ describe('OpenClaudeHookService-compatible install', () => {
const parsed = JSON.parse(readFileSync(openClaudeSettings, 'utf-8'))
for (const event of ['UserPromptSubmit', 'Stop', 'StopFailure']) {
const command = parsed.hooks[event][0].hooks[0].command as string
expect(command).toContain(OPENCLAUDE_SCRIPT_FILE_NAME)
if (process.platform === 'win32') {
expect(command).toMatch(
/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
)
} else {
expect(command).toContain(OPENCLAUDE_SCRIPT_FILE_NAME)
expect(command).toMatch(/^if \[ -x /)
}
}
expect(
readFileSync(join(tmpHome, '.orca', 'agent-hooks', OPENCLAUDE_SCRIPT_FILE_NAME), 'utf-8')
+6 -3
View File
@@ -5,6 +5,7 @@ import {
getSharedManagedScriptPath,
removeManagedCommands,
wrapPosixHookCommand,
wrapWindowsHookCommand,
type HookDefinition,
type HooksConfig
} from '../agent-hooks/installer-utils'
@@ -74,9 +75,11 @@ export function getRemoteConfigPath(remoteHome: string, settings = CLAUDE_HOOK_S
export function getManagedCommand(scriptPath: string): string {
if (process.platform === 'win32') {
// Why: Claude Code runs hooks through Git Bash on Windows; forward slashes
// survive that shell layer while native Windows APIs still accept them.
return scriptPath.replaceAll('\\', '/')
// Why: Claude Code runs hooks through Git Bash on Windows. Forward slashes
// alone don't survive a path with spaces — bash splits at the space and
// tries to execute `C:/Users/Jorge` as a command. Wrapping in
// `cmd.exe /d /c call "..."` keeps the path as one argument. #6078.
return wrapWindowsHookCommand(scriptPath)
}
return wrapPosixHookCommand(scriptPath)
}
@@ -60,6 +60,9 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalFontFamily: 'JetBrains Mono',
terminalFontWeight: 500,
terminalLineHeight: 1,
terminalScrollSensitivity: 1.15,
terminalFastScrollSensitivity: 5,
terminalTuiScrollSensitivity: 3,
terminalGpuAcceleration: 'auto',
terminalLigatures: 'auto',
terminalCursorStyle: 'block',
+3
View File
@@ -64,6 +64,9 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalFontFamily: 'JetBrains Mono',
terminalFontWeight: 500,
terminalLineHeight: 1,
terminalScrollSensitivity: 1.15,
terminalFastScrollSensitivity: 5,
terminalTuiScrollSensitivity: 3,
terminalGpuAcceleration: 'auto',
terminalLigatures: 'auto',
terminalCursorStyle: 'block',
+53 -9
View File
@@ -13,7 +13,7 @@ import {
import { tmpdir } from 'os'
import type * as Os from 'os'
import { join } from 'path'
import { wrapPosixHookCommand } from '../agent-hooks/installer-utils'
import { createManagedCommandMatcher, wrapPosixHookCommand } from '../agent-hooks/installer-utils'
import { upsertHookTrustEntriesInContent } from './config-toml-trust'
const { getPathMock, homedirMock } = vi.hoisted(() => ({
@@ -39,6 +39,11 @@ import { CodexHookService } from './hook-service'
let tmpHome: string
let userDataDir: string
function isCodexManagedCommand(command: string | undefined): boolean {
const scriptFileName = process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh'
return createManagedCommandMatcher(scriptFileName)(command)
}
let previousUserDataPath: string | undefined
beforeEach(() => {
@@ -118,8 +123,9 @@ describe('CodexHookService', () => {
'UserPromptSubmit'
].sort()
)
expect(hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command).toContain('agent-hooks')
expect(hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command).toContain('codex-hook')
expect(
isCodexManagedCommand(hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command)
).toBe(true)
const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')
expect(trustConfig).toContain('model = "gpt-5.2-codex"')
@@ -127,6 +133,40 @@ describe('CodexHookService', () => {
expect(trustConfig).toContain(':permission_request:0:0')
})
// Why: #6078 — a Windows user profile path like `C:\Users\Jane Doe` used to
// be written verbatim as the hook command, so Codex split it at the space and
// the hook exited with code 1. The managed command uses an encoded launcher
// so the path never appears raw on the cmd.exe command line.
it.skipIf(process.platform !== 'win32')(
'wraps the managed hook command in an encoded launcher when the profile path contains a space (#6078)',
() => {
const spaceHome = join(tmpdir(), 'orca home with spaces')
mkdirSync(spaceHome, { recursive: true })
homedirMock.mockReturnValue(spaceHome)
try {
const systemCodexHome = join(spaceHome, '.codex')
mkdirSync(systemCodexHome, { recursive: true })
const status = new CodexHookService().install()
expect(status.state).toBe('installed')
const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home')
const hooksConfig = JSON.parse(
readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8')
) as { hooks: Record<string, { hooks?: { command?: string }[] }[]> }
for (const eventName of ['SessionStart', 'UserPromptSubmit', 'Stop']) {
const command = hooksConfig.hooks[eventName]?.[0]?.hooks?.[0]?.command
expect(command).toMatch(
/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
)
}
} finally {
rmSync(spaceHome, { recursive: true, force: true })
}
}
)
it('keeps hooks isolated by Orca userData instead of mutating system ~/.codex', () => {
const systemCodexHome = join(tmpHome, '.codex')
const systemHooksPath = join(systemCodexHome, 'hooks.json')
@@ -177,12 +217,12 @@ describe('CodexHookService', () => {
).toBe(true)
expect(
devHooks.hooks.Stop?.some((definition) =>
definition.hooks?.[0]?.command?.includes('codex-hook')
isCodexManagedCommand(definition.hooks?.[0]?.command)
)
).toBe(true)
expect(
prodHooks.hooks.Stop?.some((definition) =>
definition.hooks?.[0]?.command?.includes('codex-hook')
isCodexManagedCommand(definition.hooks?.[0]?.command)
)
).toBe(true)
expect(readFileSync(systemHooksPath, 'utf-8')).toBe(existingSystemHooks)
@@ -296,7 +336,9 @@ describe('CodexHookService', () => {
hooks: Record<string, { hooks?: { command?: string }[] }[]>
}
expect(runtimeHooks.hooks.PostToolUse?.[0]?.hooks?.[0]?.command).toContain('codex-hook')
expect(isCodexManagedCommand(runtimeHooks.hooks.PostToolUse?.[0]?.hooks?.[0]?.command)).toBe(
true
)
expect(runtimeHooks.hooks.PostToolUse?.[1]?.hooks?.[0]?.command).toBe(
'slow-user-post-tool-hook'
)
@@ -430,7 +472,7 @@ describe('CodexHookService', () => {
) ?? []
expect(stopCommands).toContain(userCommand)
expect(stopCommands.some((command) => command.includes('codex-hook'))).toBe(true)
expect(stopCommands.some((command) => isCodexManagedCommand(command))).toBe(true)
expect(runtimeHooks.hooks.PreCompact).toBeUndefined()
for (const command of pluginCommands) {
expect(runtimeHooksText).not.toContain(command)
@@ -953,8 +995,10 @@ describe('CodexHookService', () => {
(definition) => definition.hooks?.map((hook) => hook.command ?? '') ?? []
) ?? []
expect(stopCommands).toContain(userCommand)
expect(stopCommands.some((command) => command.includes('codex-hook'))).toBe(true)
expect(runtimeHooks.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command).toContain('codex-hook')
expect(stopCommands.some((command) => isCodexManagedCommand(command))).toBe(true)
expect(
isCodexManagedCommand(runtimeHooks.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command)
).toBe(true)
const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')
expect(runtimeToml).toContain('[features]\nhooks = true')
+4 -1
View File
@@ -11,6 +11,7 @@ import {
readHooksJson,
removeManagedCommands,
wrapPosixHookCommand,
wrapWindowsHookCommand,
writeHooksJson,
writeManagedScript,
type HookCommandConfig,
@@ -111,7 +112,9 @@ function getManagedScriptPath(): string {
}
function getManagedCommand(scriptPath: string): string {
return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath)
return process.platform === 'win32'
? wrapWindowsHookCommand(scriptPath)
: wrapPosixHookCommand(scriptPath)
}
function getSystemConfigPath(): string {
+36 -5
View File
@@ -48,15 +48,46 @@ describe('CommandCodeHookService', () => {
expect(config.hooks.PreToolUse[0].matcher).toBe('.*')
expect(config.hooks.PostToolUse[0].matcher).toBe('.*')
expect(config.hooks.Stop[0].matcher).toBeUndefined()
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain('command-code-hook')
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca'))
if (process.platform === 'win32') {
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain('command-code-hook.cmd')
} else {
expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch(
process.platform === 'win32'
? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
: /command-code-hook/
)
if (process.platform !== 'win32') {
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca'))
}
if (process.platform !== 'win32') {
expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch(/^if \[ -x /)
}
})
// Why: #6078 — a Windows user profile path with a space used to be written
// verbatim as the hook command, so the agent split it at the space. The
// managed command must use an encoded launcher so the path never appears raw
// on the cmd.exe command line.
it.skipIf(process.platform !== 'win32')(
'wraps the managed hook command to survive spaces in the profile path (#6078)',
() => {
const spaceHome = join(tmpdir(), 'orca command-code home with spaces')
mkdirSync(spaceHome, { recursive: true })
homedirMock.mockReturnValue(spaceHome)
try {
expect(new CommandCodeHookService().install().state).toBe('installed')
const config = JSON.parse(
readFileSync(join(spaceHome, '.commandcode', 'settings.json'), 'utf8')
) as { hooks: Record<string, { hooks: { command: string }[] }[]> }
const command = config.hooks.PreToolUse[0].hooks[0].command
expect(command).toMatch(
/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
)
} finally {
rmSync(spaceHome, { recursive: true, force: true })
}
}
)
it('installs a hook script that can recover the endpoint when Command Code strips token env', () => {
new CommandCodeHookService().install()
+4 -1
View File
@@ -10,6 +10,7 @@ import {
readHooksJson,
removeManagedCommands,
wrapPosixHookCommand,
wrapWindowsHookCommand,
writeHooksJson,
writeManagedScript,
type HookDefinition
@@ -45,7 +46,9 @@ function getManagedScriptPath(): string {
}
function getManagedCommand(scriptPath: string): string {
return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath)
return process.platform === 'win32'
? wrapWindowsHookCommand(scriptPath)
: wrapPosixHookCommand(scriptPath)
}
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
+42 -3
View File
@@ -58,8 +58,14 @@ describe('CursorHookService', () => {
expect(Object.keys(config.hooks).sort()).toEqual([...CURSOR_EVENTS].sort())
for (const eventName of CURSOR_EVENTS) {
const definition = config.hooks[eventName]?.[0]
expect(definition?.command).toContain('cursor-hook')
expect(definition?.command).toContain(join(homeDir, '.orca'))
expect(definition?.command).toMatch(
process.platform === 'win32'
? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
: /cursor-hook/
)
if (process.platform !== 'win32') {
expect(definition?.command).toContain(join(homeDir, '.orca'))
}
expect(definition?.hooks).toBeUndefined()
}
@@ -75,6 +81,35 @@ describe('CursorHookService', () => {
}
})
// Why: #6078 — a Windows user profile path with a space used to be written
// verbatim as the hook command, so the agent split it at the space. The
// managed command must use an encoded launcher so the path never appears raw
// on the cmd.exe command line.
it.skipIf(process.platform !== 'win32')(
'wraps the managed hook command to survive spaces in the profile path (#6078)',
() => {
const spaceHome = join(tmpdir(), 'orca cursor home with spaces')
mkdirSync(spaceHome, { recursive: true })
homedirMock.mockReturnValue(spaceHome)
try {
expect(new CursorHookService().install().state).toBe('installed')
const config = JSON.parse(
readFileSync(join(spaceHome, '.cursor', 'hooks.json'), 'utf8')
) as { hooks: Record<string, { command?: string }[]> }
for (const eventName of ['beforeSubmitPrompt', 'stop']) {
const command = config.hooks[eventName]?.[0]?.command
expect(command).toMatch(
/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
)
}
} finally {
rmSync(spaceHome, { recursive: true, force: true })
}
}
)
it('preserves user-authored Cursor hook entries and removes stale managed entries', () => {
const configPath = join(homeDir, '.cursor', 'hooks.json')
mkdirSync(dirname(configPath), { recursive: true })
@@ -107,7 +142,11 @@ describe('CursorHookService', () => {
const promptCommands = config.hooks.beforeSubmitPrompt.map((definition) => definition.command)
expect(promptCommands).toContain('/usr/local/bin/user-hook')
expect(
promptCommands.filter((command) => command?.includes(CURSOR_SCRIPT_FILE_NAME))
promptCommands.filter((command) =>
process.platform === 'win32'
? command?.startsWith('powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ')
: command?.includes(CURSOR_SCRIPT_FILE_NAME)
)
).toHaveLength(1)
expect(config.hooks.retiredEvent.map((definition) => definition.command)).toEqual([
'/usr/local/bin/retired-user-hook'
+4 -1
View File
@@ -9,6 +9,7 @@ import {
readHooksJson,
removeManagedCommands,
wrapPosixHookCommand,
wrapWindowsHookCommand,
writeHooksJson,
writeManagedScript,
type HookDefinition
@@ -59,7 +60,9 @@ function getManagedScriptPath(): string {
}
function getManagedCommand(scriptPath: string): string {
return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath)
return process.platform === 'win32'
? wrapWindowsHookCommand(scriptPath)
: wrapPosixHookCommand(scriptPath)
}
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
+32
View File
@@ -1424,6 +1424,38 @@ describe('createPtySubprocess', () => {
expect(spawnMock).not.toHaveBeenCalled()
})
it('normalizes MSYS drive cwd before spawning daemon PowerShell on Windows', () => {
const proc = mockPtyProcess()
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'win32' })
spawnMock.mockImplementation((_shell, _args, options) => {
if (options.cwd === '/c/Users/alice/project') {
throw new Error('Cannot create process, error code: 267')
}
return proc
})
try {
createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24,
cwd: '/c/Users/alice/project',
shellOverride: 'powershell.exe'
})
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
expect(spawnMock).toHaveBeenCalledWith(
'powershell.exe',
POWERSHELL_OSC133_COMMAND_ARGS,
expect.objectContaining({ cwd: 'C:\\Users\\alice\\project' })
)
})
it('validates the requested Windows cwd before launching WSL on Windows', () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'win32' })
+37 -2
View File
@@ -70,11 +70,46 @@ describe('DroidHookService', () => {
expect(config.hooks.PreToolUse[0].matcher).toBe('*')
expect(config.hooks.PermissionRequest[0].matcher).toBe('*')
expect(config.hooks.UserPromptSubmit[0].matcher).toBeUndefined()
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain('droid-hook')
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca'))
expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch(
process.platform === 'win32'
? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
: /droid-hook/
)
if (process.platform !== 'win32') {
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca'))
}
expect(config.hooks.PreToolUse[0].hooks[0].command).not.toContain(userDataDir)
})
// Why: #6078 — a Windows user profile path with a space used to be written
// verbatim as the hook command, so the agent split it at the space. The
// managed command must use an encoded launcher so the path never appears raw
// on the cmd.exe command line.
it.skipIf(process.platform !== 'win32')(
'wraps the managed hook command to survive spaces in the profile path (#6078)',
() => {
const spaceHome = join(tmpdir(), 'orca droid home with spaces')
mkdirSync(spaceHome, { recursive: true })
homedirMock.mockReturnValue(spaceHome)
try {
expect(new DroidHookService().install().state).toBe('installed')
const config = JSON.parse(
readFileSync(join(spaceHome, '.factory', 'settings.json'), 'utf8')
) as { hooks: Record<string, { hooks: { command: string }[] }[]> }
for (const eventName of ['SessionStart', 'UserPromptSubmit', 'Stop']) {
const command = config.hooks[eventName]?.[0]?.hooks?.[0]?.command
expect(command).toMatch(
/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
)
}
} finally {
rmSync(spaceHome, { recursive: true, force: true })
}
}
)
it('reports partial when Factory has hooks disabled globally', () => {
const configPath = join(homeDir, '.factory', 'settings.json')
mkdirSync(dirname(configPath), { recursive: true })
+7 -3
View File
@@ -8,6 +8,7 @@ import {
readHooksJson,
removeManagedCommands,
wrapPosixHookCommand,
wrapWindowsHookCommand,
writeHooksJson,
writeManagedScript,
type HookDefinition
@@ -54,9 +55,12 @@ function getManagedScriptPath(): string {
}
function getManagedCommand(scriptPath: string): string {
// Why: Factory invokes the .cmd directly via cmd.exe (no bash), so native
// backslashes are correct on Windows. Matches the codex/cursor pattern.
return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath)
// Why: Factory invokes the .cmd via cmd.exe, but the raw path still splits at
// whitespace when the user profile contains a space (e.g. `C:\Users\Jane Doe`).
// The shared Windows wrapper keeps the path out of cmd.exe's raw command line. #6078.
return process.platform === 'win32'
? wrapWindowsHookCommand(scriptPath)
: wrapPosixHookCommand(scriptPath)
}
function getManagedScript(): string {
+43 -5
View File
@@ -100,12 +100,46 @@ describe('GeminiHookService', () => {
expect(config.hooks.PreToolUse).toBeUndefined()
expect(config.hooks.BeforeAgent).toHaveLength(2)
expect(config.hooks.BeforeAgent[0].hooks[0].command).toBe('echo user-before-agent')
expect(config.hooks.BeforeAgent[1].hooks[0].command).toContain(managedHookPath)
expect(config.hooks.AfterAgent[0].hooks[0].command).toContain(managedHookPath)
expect(config.hooks.AfterTool[0].hooks[0].command).toContain(managedHookPath)
expect(config.hooks.BeforeTool[0].hooks[0].command).toContain(managedHookPath)
const managedCommandPattern =
process.platform === 'win32'
? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
: new RegExp(managedHookPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
expect(config.hooks.BeforeAgent[1].hooks[0].command).toMatch(managedCommandPattern)
expect(config.hooks.AfterAgent[0].hooks[0].command).toMatch(managedCommandPattern)
expect(config.hooks.AfterTool[0].hooks[0].command).toMatch(managedCommandPattern)
expect(config.hooks.BeforeTool[0].hooks[0].command).toMatch(managedCommandPattern)
})
// Why: #6078 — a Windows user profile path with a space used to be written
// verbatim as the hook command, so the agent split it at the space. The
// managed command must use an encoded launcher so the path never appears raw
// on the cmd.exe command line.
it.skipIf(process.platform !== 'win32')(
'wraps the managed hook command to survive spaces in the profile path (#6078)',
() => {
const spaceHome = join(tmpdir(), 'orca gemini home with spaces')
mkdirSync(spaceHome, { recursive: true })
homedirMock.mockReturnValue(spaceHome)
try {
expect(new GeminiHookService().install().state).toBe('installed')
const config = JSON.parse(
readFileSync(join(spaceHome, '.gemini', 'settings.json'), 'utf8')
) as { hooks: Record<string, { hooks: { command: string }[] }[]> }
for (const eventName of ['BeforeAgent', 'AfterAgent', 'AfterTool']) {
const command = config.hooks[eventName]?.[0]?.hooks?.[0]?.command
expect(command).toMatch(
/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
)
}
} finally {
rmSync(spaceHome, { recursive: true, force: true })
homedirMock.mockReturnValue(homeDir)
}
}
)
it('preserves user-authored PreToolUse hooks while sweeping stale managed Gemini hooks', () => {
const managedHookFileName = process.platform === 'win32' ? 'gemini-hook.cmd' : 'gemini-hook.sh'
const staleManagedHookPath =
@@ -147,6 +181,10 @@ describe('GeminiHookService', () => {
expect(status.state).toBe('installed')
expect(preToolCommands).toEqual(['echo user-authored'])
expect(config.hooks.BeforeTool[0].hooks[0].command).toContain(managedHookFileName)
expect(config.hooks.BeforeTool[0].hooks[0].command).toMatch(
process.platform === 'win32'
? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
: new RegExp(managedHookFileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
)
})
})
+4 -1
View File
@@ -9,6 +9,7 @@ import {
readHooksJson,
removeManagedCommands,
wrapPosixHookCommand,
wrapWindowsHookCommand,
writeHooksJson,
writeManagedScript,
type HookDefinition
@@ -43,7 +44,9 @@ function getManagedScriptPath(): string {
}
function getManagedCommand(scriptPath: string): string {
return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath)
return process.platform === 'win32'
? wrapWindowsHookCommand(scriptPath)
: wrapPosixHookCommand(scriptPath)
}
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
+101 -1
View File
@@ -110,7 +110,8 @@ import {
updatePRTitle,
_getMergeQueueCacheSizeForTests,
_resetOwnerRepoCache,
_resetMergeQueueCacheForTests
_resetMergeQueueCacheForTests,
__resetTrackedUpstreamBranchCacheForTests
} from './client'
describe('checkOrcaStarred', () => {
@@ -179,6 +180,7 @@ describe('getPRForBranch', () => {
acquireMock.mockResolvedValue(undefined)
_resetOwnerRepoCache()
_resetMergeQueueCacheForTests()
__resetTrackedUpstreamBranchCacheForTests()
})
it('queries GitHub by head branch when the remote is on github.com', async () => {
@@ -1160,6 +1162,104 @@ describe('getPRForBranch', () => {
})
})
it('does not repeat missing tracked-upstream probes during PR refresh polling', async () => {
resolvePRRepositoryCandidatesMock.mockResolvedValue({
candidates: [{ owner: 'acme', repo: 'widgets' }],
headRepo: { owner: 'acme', repo: 'widgets' }
})
ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) })
gitExecFileAsyncMock.mockRejectedValue(
new Error("fatal: no upstream configured for branch 'no-pr-branch'")
)
await getPRForBranch('/repo-root', 'no-pr-branch')
await getPRForBranch('/repo-root', 'no-pr-branch')
await getPRForBranch('/repo-root', 'no-pr-branch')
const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) =>
(args as string[]).includes('no-pr-branch@{upstream}')
)
expect(trackedUpstreamCalls).toHaveLength(1)
})
it('coalesces concurrent missing tracked-upstream probes', async () => {
resolvePRRepositoryCandidatesMock.mockResolvedValue({
candidates: [{ owner: 'acme', repo: 'widgets' }],
headRepo: { owner: 'acme', repo: 'widgets' }
})
ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) })
gitExecFileAsyncMock.mockImplementation(async () => {
await Promise.resolve()
throw new Error("fatal: no upstream configured for branch 'no-pr-branch'")
})
await Promise.all([
getPRForBranch('/repo-root', 'no-pr-branch'),
getPRForBranch('/repo-root', 'no-pr-branch'),
getPRForBranch('/repo-root', 'no-pr-branch')
])
const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) =>
(args as string[]).includes('no-pr-branch@{upstream}')
)
expect(trackedUpstreamCalls).toHaveLength(1)
})
it('keeps missing tracked-upstream probes separate for host and WSL runtimes', async () => {
resolvePRRepositoryCandidatesMock.mockResolvedValue({
candidates: [{ owner: 'acme', repo: 'widgets' }],
headRepo: { owner: 'acme', repo: 'widgets' }
})
ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) })
gitExecFileAsyncMock.mockRejectedValue(
new Error("fatal: no upstream configured for branch 'no-pr-branch'")
)
await getPRForBranch('/repo-root', 'no-pr-branch')
await getPRForBranch('/repo-root', 'no-pr-branch', null, null, null, {
localGitExecOptions: { wslDistro: 'Ubuntu' }
})
await getPRForBranch('/repo-root', 'no-pr-branch')
await getPRForBranch('/repo-root', 'no-pr-branch', null, null, null, {
localGitExecOptions: { wslDistro: 'Ubuntu' }
})
const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) =>
(args as string[]).includes('no-pr-branch@{upstream}')
)
expect(trackedUpstreamCalls).toHaveLength(2)
expect(trackedUpstreamCalls[0][1]).toEqual({ cwd: '/repo-root' })
expect(trackedUpstreamCalls[1][1]).toEqual({
cwd: '/repo-root',
wslDistro: 'Ubuntu'
})
})
it('rechecks missing tracked-upstream probes after the null-cache TTL expires', async () => {
vi.useFakeTimers()
try {
resolvePRRepositoryCandidatesMock.mockResolvedValue({
candidates: [{ owner: 'acme', repo: 'widgets' }],
headRepo: { owner: 'acme', repo: 'widgets' }
})
ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) })
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error("fatal: no upstream configured for branch 'feature'"))
.mockResolvedValueOnce({ stdout: 'origin/contributor/original\n', stderr: '' })
await getPRForBranch('/repo-root', 'feature')
await vi.advanceTimersByTimeAsync(30_001)
await getPRForBranch('/repo-root', 'feature')
const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) =>
(args as string[]).includes('feature@{upstream}')
)
expect(trackedUpstreamCalls).toHaveLength(2)
} finally {
vi.useRealTimers()
}
})
it('uses the tracked upstream remote owner for fork branch lookup', async () => {
resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({
candidates: [
+73
View File
@@ -2273,6 +2273,20 @@ type TrackedUpstreamBranch = {
branchName: string
}
const TRACKED_UPSTREAM_NULL_CACHE_TTL_MS = 30_000
type TrackedUpstreamNullCacheEntry = {
expiresAt: number
}
const trackedUpstreamNullCache = new Map<string, TrackedUpstreamNullCacheEntry>()
const trackedUpstreamInFlight = new Map<string, Promise<TrackedUpstreamBranch | null>>()
export function __resetTrackedUpstreamBranchCacheForTests(): void {
trackedUpstreamNullCache.clear()
trackedUpstreamInFlight.clear()
}
function parseTrackedUpstreamBranch(
upstreamRef: string,
branchName: string
@@ -2289,6 +2303,65 @@ async function getTrackedUpstreamBranch(
branchName: string,
connectionId?: string | null,
localGitOptions: { wslDistro?: string } = {}
): Promise<TrackedUpstreamBranch | null> {
// Why: branches without configured upstreams are stable misses during PR
// polling; cache only nulls so positive PR discovery stays fresh.
const cacheKey = getTrackedUpstreamBranchCacheKey(
repoPath,
branchName,
connectionId,
localGitOptions
)
const now = Date.now()
const cachedNull = trackedUpstreamNullCache.get(cacheKey)
if (cachedNull && cachedNull.expiresAt > now) {
return null
}
if (cachedNull) {
trackedUpstreamNullCache.delete(cacheKey)
}
const inFlight = trackedUpstreamInFlight.get(cacheKey)
if (inFlight) {
return inFlight
}
const probe = probeTrackedUpstreamBranch(repoPath, branchName, connectionId, localGitOptions)
trackedUpstreamInFlight.set(cacheKey, probe)
try {
const result = await probe
if (result) {
trackedUpstreamNullCache.delete(cacheKey)
} else {
trackedUpstreamNullCache.set(cacheKey, {
expiresAt: now + TRACKED_UPSTREAM_NULL_CACHE_TTL_MS
})
}
return result
} finally {
if (trackedUpstreamInFlight.get(cacheKey) === probe) {
trackedUpstreamInFlight.delete(cacheKey)
}
}
}
function getTrackedUpstreamBranchCacheKey(
repoPath: string,
branchName: string,
connectionId?: string | null,
localGitOptions: { wslDistro?: string } = {}
): string {
const runtimeKey = connectionId
? `ssh:${connectionId}`
: `local:${localGitOptions.wslDistro ?? 'host'}`
return [runtimeKey, repoPath, branchName].join('\0')
}
async function probeTrackedUpstreamBranch(
repoPath: string,
branchName: string,
connectionId?: string | null,
localGitOptions: { wslDistro?: string } = {}
): Promise<TrackedUpstreamBranch | null> {
const args = ['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${branchName}@{upstream}`]
try {
+44 -3
View File
@@ -59,8 +59,14 @@ describe('GrokHookService', () => {
expect(config.hooks.PreToolUse[0].matcher).toBe('*')
expect(config.hooks.PostToolUseFailure[0].matcher).toBe('*')
expect(config.hooks.Notification[0].matcher).toBeUndefined()
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain('grok-hook')
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca'))
expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch(
process.platform === 'win32'
? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
: /grok-hook/
)
if (process.platform !== 'win32') {
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca'))
}
const script = readFileSync(
join(homeDir, '.orca', 'agent-hooks', GROK_SCRIPT_FILE_NAME),
@@ -74,6 +80,35 @@ describe('GrokHookService', () => {
}
})
// Why: #6078 — a Windows user profile path with a space used to be written
// verbatim as the hook command, so the agent split it at the space. The
// managed command must use an encoded launcher so the path never appears raw
// on the cmd.exe command line.
it.skipIf(process.platform !== 'win32')(
'wraps the managed hook command to survive spaces in the profile path (#6078)',
() => {
const spaceHome = join(tmpdir(), 'orca grok home with spaces')
mkdirSync(spaceHome, { recursive: true })
homedirMock.mockReturnValue(spaceHome)
try {
expect(new GrokHookService().install().state).toBe('installed')
const config = JSON.parse(
readFileSync(join(spaceHome, '.grok', 'hooks', 'orca-status.json'), 'utf8')
) as { hooks: Record<string, { hooks: { command: string }[] }[]> }
for (const eventName of ['SessionStart', 'UserPromptSubmit', 'Stop']) {
const command = config.hooks[eventName]?.[0]?.hooks?.[0]?.command
expect(command).toMatch(
/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
)
}
} finally {
rmSync(spaceHome, { recursive: true, force: true })
}
}
)
it('preserves user-authored hook entries in the Orca Grok config file', () => {
const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json')
mkdirSync(dirname(configPath), { recursive: true })
@@ -99,6 +134,12 @@ describe('GrokHookService', () => {
definition.hooks.map((hook) => hook.command)
)
expect(commands).toContain('/usr/local/bin/user-hook')
expect(commands.some((command) => command.includes(GROK_SCRIPT_FILE_NAME))).toBe(true)
expect(
commands.some((command) =>
process.platform === 'win32'
? command.startsWith('powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ')
: command.includes(GROK_SCRIPT_FILE_NAME)
)
).toBe(true)
})
})
+4 -1
View File
@@ -9,6 +9,7 @@ import {
readHooksJson,
removeManagedCommands,
wrapPosixHookCommand,
wrapWindowsHookCommand,
writeHooksJson,
writeManagedScript,
type HookDefinition
@@ -55,7 +56,9 @@ function getManagedScriptPath(): string {
}
function getManagedCommand(scriptPath: string): string {
return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath)
return process.platform === 'win32'
? wrapWindowsHookCommand(scriptPath)
: wrapPosixHookCommand(scriptPath)
}
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
@@ -0,0 +1,16 @@
import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path'
import { getPreflightWslTarget, type PreflightRuntimeContext } from './preflight-runtime-target'
export async function hydrateShellPathForAgentDetection(
context?: PreflightRuntimeContext
): Promise<void> {
if (getPreflightWslTarget(context)) {
return
}
// Why: remote runtime servers may inherit a sparse daemon/SSH PATH even
// though the user's shell can run the agents.
const hydration = await hydrateShellPath()
if (hydration.ok) {
mergePathSegments(hydration.segments)
}
}
+45 -10
View File
@@ -1,14 +1,24 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { handlers, appExitMock, appQuitMock, appRelaunchMock, execFileMock, destroySystemTrayMock } =
vi.hoisted(() => ({
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
appExitMock: vi.fn(),
appQuitMock: vi.fn(),
appRelaunchMock: vi.fn(),
execFileMock: vi.fn(),
destroySystemTrayMock: vi.fn()
}))
const {
handlers,
appExitMock,
appQuitMock,
appRelaunchMock,
execFileMock,
destroySystemTrayMock,
showOpenDialogMock,
grantFloatingWorkspaceDirectoryMock
} = vi.hoisted(() => ({
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
appExitMock: vi.fn(),
appQuitMock: vi.fn(),
appRelaunchMock: vi.fn(),
execFileMock: vi.fn(),
destroySystemTrayMock: vi.fn(),
showOpenDialogMock: vi.fn(),
grantFloatingWorkspaceDirectoryMock: vi.fn()
}))
vi.mock('node:child_process', () => ({
execFile: execFileMock
@@ -26,7 +36,7 @@ vi.mock('electron', () => ({
fromWebContents: vi.fn(() => null)
},
dialog: {
showOpenDialog: vi.fn()
showOpenDialog: showOpenDialogMock
},
ipcMain: {
handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => {
@@ -43,6 +53,12 @@ vi.mock('../tray/system-tray', () => ({
destroySystemTray: destroySystemTrayMock
}))
vi.mock('./floating-workspace-directory', () => ({
ensureDefaultFloatingWorkspacePath: vi.fn(),
grantFloatingWorkspaceDirectory: grantFloatingWorkspaceDirectoryMock,
resolveFloatingTerminalCwd: vi.fn()
}))
import { registerAppHandlers } from './app'
describe('registerAppHandlers', () => {
@@ -56,6 +72,8 @@ describe('registerAppHandlers', () => {
appRelaunchMock.mockReset()
execFileMock.mockReset()
destroySystemTrayMock.mockReset()
showOpenDialogMock.mockReset()
grantFloatingWorkspaceDirectoryMock.mockReset()
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
})
@@ -177,4 +195,21 @@ describe('registerAppHandlers', () => {
await expect(resultPromise).resolves.toBeNull()
expect(killMock).toHaveBeenCalled()
})
it('picks an existing floating workspace directory without enabling native directory creation', async () => {
const store = {}
showOpenDialogMock.mockResolvedValue({
canceled: false,
filePaths: ['/Users/kaylee/notes']
})
registerAppHandlers(store as never)
await expect(
handlers.get('app:pickFloatingWorkspaceDirectory')?.({ sender: {} })
).resolves.toBe('/Users/kaylee/notes')
expect(showOpenDialogMock).toHaveBeenCalledWith({
properties: ['openDirectory']
})
expect(grantFloatingWorkspaceDirectoryMock).toHaveBeenCalledWith(store, '/Users/kaylee/notes')
})
})
+3 -1
View File
@@ -57,7 +57,9 @@ async function pickFloatingWorkspaceDirectory(
): Promise<string | null> {
const parentWindow = BrowserWindow.fromWebContents(event.sender)
const options = {
properties: ['openDirectory', 'createDirectory']
// Why: this picker grants access to an existing workspace directory.
// Creation belongs to explicit file/write actions, not typeahead input.
properties: ['openDirectory']
} satisfies Electron.OpenDialogOptions
const result = parentWindow
? await dialog.showOpenDialog(parentWindow, options)
+59
View File
@@ -76,6 +76,7 @@ vi.mock('../gitea/client', () => ({
import {
_resetPreflightCache,
detectInstalledAgents,
detectInstalledAgentsWithShellPathHydration,
registerPreflightHandlers,
runPreflightCheck
} from './preflight'
@@ -105,6 +106,7 @@ describe('preflight', () => {
handleMock.mockReset()
execFileAsyncMock.mockReset()
hydrateShellPathMock.mockReset()
hydrateShellPathMock.mockResolvedValue({ segments: [], ok: false, failureReason: 'no_shell' })
mergePathSegmentsMock.mockReset()
getActiveMultiplexerMock.mockReset()
getBitbucketAuthStatusMock.mockReset()
@@ -575,6 +577,63 @@ describe('preflight', () => {
await expect(handlers['preflight:detectAgents']()).resolves.toEqual(['openclaude', 'cursor'])
})
it('hydrates shell PATH before user-facing agent detection', async () => {
const originalPath = process.env.PATH
process.env.PATH = '/usr/bin'
hydrateShellPathMock.mockResolvedValueOnce({
segments: ['/home/test/.local/bin'],
ok: true,
failureReason: 'none'
})
mergePathSegmentsMock.mockImplementationOnce((segments: string[]) => {
process.env.PATH = [...segments, '/usr/bin'].join(':')
return segments
})
execFileAsyncMock.mockImplementation(async (command, args) => {
if (command !== 'which') {
throw new Error(`unexpected command ${String(command)}`)
}
if (String(args[0]) === 'codex' && process.env.PATH?.startsWith('/home/test/.local/bin')) {
return { stdout: '/home/test/.local/bin/codex\n' }
}
throw new Error('not found')
})
try {
await expect(detectInstalledAgentsWithShellPathHydration()).resolves.toEqual(['codex'])
} finally {
if (originalPath === undefined) {
delete process.env.PATH
} else {
process.env.PATH = originalPath
}
}
expect(hydrateShellPathMock).toHaveBeenCalledWith()
expect(mergePathSegmentsMock).toHaveBeenCalledWith(['/home/test/.local/bin'])
})
it('does not run host shell hydration for WSL agent detection', async () => {
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
execFileAsyncMock.mockImplementation(async (command, args) => {
if (command !== 'wsl.exe') {
throw new Error(`unexpected command ${String(command)}`)
}
const script = String(args[5])
if (script.includes("'claude'")) {
return { stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n' }
}
throw new Error('not found')
})
await expect(
detectInstalledAgentsWithShellPathHydration({ wslDistro: 'Ubuntu' })
).resolves.toEqual(['claude'])
expect(hydrateShellPathMock).not.toHaveBeenCalled()
})
it('detects Mistral Vibe from the installed vibe executable', async () => {
execFileAsyncMock.mockImplementation(async (command, args) => {
if (command !== 'which') {
+10 -5
View File
@@ -15,6 +15,7 @@ import { runPreflightCommandInWsl } from './preflight-wsl-command'
import { detectCommandsInInstallDirs } from './local-agent-install-dir-detection'
import { buildLocalPreflightEnv } from './preflight-local-env'
import { getPreflightWslTarget, type PreflightRuntimeContext } from './preflight-runtime-target'
import { hydrateShellPathForAgentDetection } from './agent-detection-shell-path'
const execFileAsync = promisify(execFile)
const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000
@@ -195,6 +196,13 @@ export async function detectInstalledAgents(context?: PreflightRuntimeContext):
return uniqueAgentIds(checks.filter((c) => c.installed).map((c) => c.id))
}
export async function detectInstalledAgentsWithShellPathHydration(
context?: PreflightRuntimeContext
): Promise<string[]> {
await hydrateShellPathForAgentDetection(context)
return detectInstalledAgents(context)
}
export type RefreshAgentsResult = {
/** Agents detected after hydrating PATH from the user's login shell. */
agents: string[]
@@ -352,11 +360,8 @@ export function registerPreflightHandlers(): void {
}
)
ipcMain.handle(
'preflight:detectAgents',
async (_event, args?: PreflightRuntimeContext): Promise<string[]> => {
return detectInstalledAgents(args)
}
ipcMain.handle('preflight:detectAgents', async (_event, args?: PreflightRuntimeContext) =>
detectInstalledAgentsWithShellPathHydration(args)
)
ipcMain.handle('preflight:refreshAgents', async (_event, args?: PreflightRuntimeContext) => {
+22
View File
@@ -64,6 +64,14 @@ describe('repos folder pickers', () => {
return handler(null, undefined) as Promise<string[]>
}
const callPickDirectory = (): Promise<string | null> => {
const handler = handlers.get('repos:pickDirectory')
if (!handler) {
throw new Error('repos:pickDirectory handler was never registered')
}
return handler(null, undefined) as Promise<string | null>
}
beforeEach(() => {
handlers.clear()
handleMock.mockReset()
@@ -101,4 +109,18 @@ describe('repos folder pickers', () => {
await expect(callPickFolders()).resolves.toEqual([])
})
it('picks an existing directory without enabling native directory creation', async () => {
const parentDir = join(sep, 'projects')
showOpenDialogMock.mockResolvedValue({
canceled: false,
filePaths: [parentDir]
})
await expect(callPickDirectory()).resolves.toBe(parentDir)
expect(showOpenDialogMock).toHaveBeenCalledWith(mockWindow, {
properties: ['openDirectory']
})
})
})
+3 -1
View File
@@ -2070,7 +2070,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
// destination directory that may not be a git repo yet.
ipcMain.handle('repos:pickDirectory', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory', 'createDirectory']
// Why: macOS can materialize typed partial paths when directory creation
// is enabled; clone/create actions already create the final path on submit.
properties: ['openDirectory']
})
if (result.canceled || result.filePaths.length === 0) {
return null
+16
View File
@@ -132,6 +132,22 @@ describe('registerShellHandlers', () => {
await expect(handler({})).resolves.toBeNull()
})
it('picks an existing directory without enabling native directory creation', async () => {
showOpenDialogMock.mockResolvedValue({
canceled: false,
filePaths: ['/Users/kaylee/projects']
})
const handler = getHandler('shell:pickDirectory')
await expect(handler({}, { defaultPath: '/Users/kaylee' })).resolves.toBe(
'/Users/kaylee/projects'
)
expect(showOpenDialogMock).toHaveBeenCalledWith({
defaultPath: '/Users/kaylee',
properties: ['openDirectory']
})
})
describe('shell:openPath', () => {
it('ignores relative paths', async () => {
const handler = getHandler('shell:openPath')
+3 -1
View File
@@ -205,7 +205,9 @@ export function registerShellHandlers(): void {
async (_event, args: { defaultPath?: string }): Promise<string | null> => {
const result = await dialog.showOpenDialog({
defaultPath: args.defaultPath,
properties: ['openDirectory', 'createDirectory']
// Why: callers only need an existing folder grant; enabling native
// creation can leave typed prefix directories behind on macOS.
properties: ['openDirectory']
})
if (result.canceled || result.filePaths.length === 0) {
return null
+2
View File
@@ -57,6 +57,7 @@ const {
onExit: vi.fn(),
onReplay: vi.fn(),
attach: vi.fn(),
attachForReconnect: vi.fn().mockResolvedValue({}),
shutdown: vi.fn()
},
mockFsProvider: {},
@@ -285,6 +286,7 @@ describe('SSH IPC handlers', () => {
mockPtyProvider.onData.mockReset()
mockPtyProvider.onExit.mockReset()
mockPtyProvider.onReplay.mockReset()
mockPtyProvider.attachForReconnect.mockReset().mockResolvedValue({})
mockPtyProvider.shutdown.mockReset()
mockPortForwardManager.addForward.mockReset()
mockPortForwardManager.updateForward.mockReset()
@@ -0,0 +1,25 @@
const worktreeChangeInvalidators = new Set<(repoId: string) => void>()
export function registerWorktreeChangeInvalidator(
invalidator: (repoId: string) => void
): () => void {
worktreeChangeInvalidators.add(invalidator)
return () => {
worktreeChangeInvalidators.delete(invalidator)
}
}
export function runWorktreeChangeInvalidators(repoId: string): void {
const invalidators = Array.from(worktreeChangeInvalidators)
for (const invalidator of invalidators) {
try {
invalidator(repoId)
} catch (error) {
console.warn('[worktrees] worktree change invalidator failed:', error)
}
}
}
export function __resetWorktreeChangeInvalidatorsForTests(): void {
worktreeChangeInvalidators.clear()
}
+4
View File
@@ -57,6 +57,7 @@ import type { SshGitProvider } from '../providers/ssh-git-provider'
import { TUI_AGENT_CONFIG, isTuiAgent } from '../../shared/tui-agent-config'
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
import { getSshGitUsername } from '../git/git-username'
import { runWorktreeChangeInvalidators } from './worktree-change-invalidators'
type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & {
automationProvenance?: AutomationWorkspaceProvenance
@@ -1328,6 +1329,9 @@ async function getRemoteLocalBaseRefUpdateSuggestionForWorktreeCreate(
}
export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void {
// Why: invalidate detected-worktree caches before renderer observers react,
// so follow-up listDetected reads post-change state.
runWorktreeChangeInvalidators(repoId)
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('worktrees:changed', { repoId })
}
+314 -3
View File
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { lstat, mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join, resolve } from 'path'
import type { CreateWorktreeResult } from '../../shared/types'
import type { CreateWorktreeResult, GitWorktreeInfo, Worktree } from '../../shared/types'
const ORIGINAL_PLATFORM = process.platform
@@ -226,9 +226,12 @@ vi.mock('./pty', () => ({
getLocalPtyProvider: getLocalPtyProviderMock
}))
import { __resetSshWorktreeCreateFetchCacheForTests } from './worktree-remote'
import {
__resetSshWorktreeCreateFetchCacheForTests,
notifyWorktreesChanged
} from './worktree-remote'
import { invalidateAuthorizedRootsCache, resolveRegisteredWorktreePath } from './filesystem-auth'
import { registerWorktreeHandlers } from './worktrees'
import { __resetDetectedWorktreeScanCacheForTests, registerWorktreeHandlers } from './worktrees'
type HandlerMap = Record<string, (_event: unknown, args: unknown) => unknown>
@@ -272,6 +275,7 @@ describe('registerWorktreeHandlers', () => {
beforeEach(() => {
setPlatform(ORIGINAL_PLATFORM)
__resetSshWorktreeCreateFetchCacheForTests()
__resetDetectedWorktreeScanCacheForTests()
invalidateAuthorizedRootsCache()
for (const m of [
handleMock,
@@ -2033,6 +2037,313 @@ describe('registerWorktreeHandlers', () => {
})
})
it('reuses a recent authoritative detected worktree scan', async () => {
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/repo',
head: 'main-head',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
])
const first = await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
const second = await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
expect(first).toEqual(second)
expect(listWorktreesMock).toHaveBeenCalledTimes(1)
})
it('coalesces concurrent authoritative detected worktree scans', async () => {
listWorktreesMock.mockImplementation(async () => {
await Promise.resolve()
return [
{
path: '/workspace/repo',
head: 'main-head',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
]
})
await Promise.all([
handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }),
handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }),
handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
])
expect(listWorktreesMock).toHaveBeenCalledTimes(1)
})
it('rechecks detected worktree metadata while reusing a cached raw scan', async () => {
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/repo',
head: 'main-head',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
])
let currentMeta = makeWorktreeMeta({ isPinned: false })
store.getWorktreeMeta.mockImplementation(() => currentMeta)
store.setWorktreeMeta.mockImplementation(() => currentMeta)
const first = (await handlers['worktrees:listDetected'](null, {
repoId: 'repo-1'
})) as { worktrees: Worktree[] }
currentMeta = makeWorktreeMeta({ isPinned: true })
const second = (await handlers['worktrees:listDetected'](null, {
repoId: 'repo-1'
})) as { worktrees: Worktree[] }
expect(first.worktrees[0].isPinned).toBe(false)
expect(second.worktrees[0].isPinned).toBe(true)
expect(listWorktreesMock).toHaveBeenCalledTimes(1)
})
it('rescans detected worktrees after the scan cache TTL expires', async () => {
vi.useFakeTimers()
try {
listWorktreesMock
.mockResolvedValueOnce([
{
path: '/workspace/repo',
head: 'main-head',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
])
.mockResolvedValueOnce([
{
path: '/workspace/repo',
head: 'main-head',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
},
{
path: '/workspace/new-worktree',
head: 'feature-head',
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false
}
])
await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
await vi.advanceTimersByTimeAsync(5_001)
const second = (await handlers['worktrees:listDetected'](null, {
repoId: 'repo-1'
})) as { worktrees: Worktree[] }
expect(second.worktrees.map((worktree) => worktree.path)).toEqual([
'/workspace/repo',
'/workspace/new-worktree'
])
expect(listWorktreesMock).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
it('starts the detected scan cache TTL after a slow scan completes', async () => {
vi.useFakeTimers()
try {
listWorktreesMock
.mockImplementationOnce(
() =>
new Promise((resolve) => {
setTimeout(
() =>
resolve([
{
path: '/workspace/repo',
head: 'main-head',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
]),
6_000
)
})
)
.mockResolvedValueOnce([
{
path: '/workspace/repo',
head: 'main-head',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
},
{
path: '/workspace/new-worktree',
head: 'feature-head',
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false
}
])
const first = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
await vi.advanceTimersByTimeAsync(6_000)
await first
const second = (await handlers['worktrees:listDetected'](null, {
repoId: 'repo-1'
})) as { worktrees: Worktree[] }
expect(second.worktrees.map((worktree) => worktree.path)).toEqual(['/workspace/repo'])
expect(listWorktreesMock).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('invalidates the detected scan cache before worktree change notifications', async () => {
listWorktreesMock
.mockResolvedValueOnce([
{
path: '/workspace/repo',
head: 'main-head',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
])
.mockResolvedValueOnce([
{
path: '/workspace/repo',
head: 'main-head',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
},
{
path: '/workspace/new-worktree',
head: 'feature-head',
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false
}
])
await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
notifyWorktreesChanged(mainWindow as never, 'repo-1')
const second = (await handlers['worktrees:listDetected'](null, {
repoId: 'repo-1'
})) as { worktrees: Worktree[] }
expect(second.worktrees).toHaveLength(2)
expect(listWorktreesMock).toHaveBeenCalledTimes(2)
})
it('rescans detected worktrees after the local create flow notifies worktree changes', async () => {
listWorktreesMock
.mockResolvedValueOnce([
{
path: '/workspace/repo',
head: 'main-head',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
])
.mockResolvedValueOnce([
{
path: '/workspace/repo',
head: 'main-head',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
},
{
path: '/workspace/improve-dashboard',
head: 'feature-head',
branch: 'refs/heads/improve-dashboard',
isBare: false,
isMainWorktree: false
}
])
.mockResolvedValueOnce([
{
path: '/workspace/repo',
head: 'main-head',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
},
{
path: '/workspace/improve-dashboard',
head: 'feature-head',
branch: 'refs/heads/improve-dashboard',
isBare: false,
isMainWorktree: false
}
])
await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard'
})
const detected = (await handlers['worktrees:listDetected'](null, {
repoId: 'repo-1'
})) as { worktrees: Worktree[] }
expect(detected.worktrees.map((worktree) => worktree.path)).toEqual([
'/workspace/repo',
'/workspace/improve-dashboard'
])
expect(listWorktreesMock).toHaveBeenCalledTimes(3)
})
it('does not run fresh-scan side effects from a detected scan invalidated while in flight', async () => {
let resolveScan: (worktrees: GitWorktreeInfo[]) => void = () => {}
listWorktreesMock.mockImplementation(
() =>
new Promise((resolve) => {
resolveScan = resolve as (worktrees: GitWorktreeInfo[]) => void
})
)
store.getAllWorktreeLineage.mockReturnValue({
'repo-1::/workspace/new-worktree': {
worktreeId: 'repo-1::/workspace/new-worktree',
worktreeInstanceId: 'child-instance',
parentWorktreeId: 'repo-1::/workspace/repo',
parentWorktreeInstanceId: 'parent-instance',
origin: 'manual',
capture: {
source: 'manual-action',
confidence: 'explicit'
},
createdAt: 0
}
})
const pendingList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
await Promise.resolve()
notifyWorktreesChanged(mainWindow as never, 'repo-1')
resolveScan([
{
path: '/workspace/repo',
head: 'main-head',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
])
await pendingList
expect(store.removeWorktreeLineage).not.toHaveBeenCalled()
expect(listWorktreesMock).toHaveBeenCalledTimes(1)
})
it('fetches the same-repo PR head via the SSH tracking-ref RPC, not git.exec', async () => {
const fetchRemoteTrackingRef = vi.fn(async () => {})
const exec = vi.fn(async (args: string[]) => {
+86 -6
View File
@@ -77,6 +77,7 @@ import {
cleanupUnusedWorktreePushTargetRemoteSsh,
notifyWorktreesChanged
} from './worktree-remote'
import { registerWorktreeChangeInvalidator } from './worktree-change-invalidators'
import {
invalidateAuthorizedRootsCache,
isENOENT,
@@ -387,6 +388,83 @@ function getPreservedBranchCleanupTarget(
const loggedUnavailableSshGitProviders = new Set<string>()
const loggedWorktreeListFailures = new Set<string>()
const loggedMalformedWorktreeMetaKeys = new Set<string>()
// Why: absorb renderer polling bursts while keeping external worktree-change
// lag bounded to one short refresh window.
const DETECTED_WORKTREE_SCAN_CACHE_TTL_MS = 5_000
type DetectedWorktreeScanCacheEntry = {
expiresAt: number
worktrees: GitWorktreeInfo[]
}
type DetectedWorktreeScanResult = {
gitWorktrees: GitWorktreeInfo[]
fresh: boolean
}
const detectedWorktreeScanCache = new Map<string, DetectedWorktreeScanCacheEntry>()
const detectedWorktreeScanInFlight = new Map<string, Promise<GitWorktreeInfo[]>>()
const detectedWorktreeScanGenerations = new Map<string, number>()
function invalidateDetectedWorktreeScanCache(repoId: string): void {
detectedWorktreeScanCache.delete(repoId)
detectedWorktreeScanInFlight.delete(repoId)
detectedWorktreeScanGenerations.set(
repoId,
(detectedWorktreeScanGenerations.get(repoId) ?? 0) + 1
)
}
registerWorktreeChangeInvalidator(invalidateDetectedWorktreeScanCache)
export function __resetDetectedWorktreeScanCacheForTests(): void {
detectedWorktreeScanCache.clear()
detectedWorktreeScanInFlight.clear()
detectedWorktreeScanGenerations.clear()
}
async function listDetectedGitWorktrees(
store: Store,
repo: Repo
): Promise<DetectedWorktreeScanResult> {
if (repo.connectionId || isFolderRepo(repo)) {
return {
gitWorktrees: await listRepoWorktrees(repo, getLocalProjectWorktreeGitOptions(store, repo)),
fresh: true
}
}
const cached = detectedWorktreeScanCache.get(repo.id)
if (cached && cached.expiresAt > Date.now()) {
return { gitWorktrees: cached.worktrees, fresh: false }
}
const inFlight = detectedWorktreeScanInFlight.get(repo.id)
if (inFlight) {
return { gitWorktrees: await inFlight, fresh: false }
}
const scan = listRepoWorktrees(repo, getLocalProjectWorktreeGitOptions(store, repo))
const generation = detectedWorktreeScanGenerations.get(repo.id) ?? 0
detectedWorktreeScanInFlight.set(repo.id, scan)
try {
const gitWorktrees = await scan
// Why: a create/remove notification can invalidate while the git scan is
// still running. Do not let that stale scan repopulate the cache afterward.
const isCurrentGeneration = (detectedWorktreeScanGenerations.get(repo.id) ?? 0) === generation
if (isCurrentGeneration) {
detectedWorktreeScanCache.set(repo.id, {
worktrees: gitWorktrees,
expiresAt: Date.now() + DETECTED_WORKTREE_SCAN_CACHE_TTL_MS
})
}
return { gitWorktrees, fresh: isCurrentGeneration }
} finally {
if (detectedWorktreeScanInFlight.get(repo.id) === scan) {
detectedWorktreeScanInFlight.delete(repo.id)
}
}
}
function warnOnce(keySet: Set<string>, key: string, message: string, error?: unknown): void {
if (keySet.has(key)) {
@@ -946,6 +1024,7 @@ export function registerWorktreeHandlers(
try {
let gitWorktrees: GitWorktreeInfo[]
let freshScan = true
if (isFolderRepo(repo)) {
return {
repoId: repo.id,
@@ -966,13 +1045,14 @@ export function registerWorktreeHandlers(
}
gitWorktrees = await provider.listWorktrees(repo.path)
} else {
gitWorktrees = await listRepoWorktrees(
repo,
getLocalProjectWorktreeGitOptions(store, repo)
)
const scan = await listDetectedGitWorktrees(store, repo)
gitWorktrees = scan.gitWorktrees
freshScan = scan.fresh
}
if (freshScan) {
rememberLocalWorktreeRoots(store, repo, gitWorktrees)
pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees)
}
rememberLocalWorktreeRoots(store, repo, gitWorktrees)
pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees)
loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`)
return {
repoId: repo.id,
+67 -2
View File
@@ -18,10 +18,10 @@ vi.mock('./client', () => ({
jiraRequest: (...args: unknown[]) => jiraRequestMock(...args)
}))
function makeEntry(): JiraClientForSite {
function makeEntry(id = 'site-1'): JiraClientForSite {
return {
site: {
id: 'site-1',
id,
siteUrl: 'https://example.atlassian.net',
email: 'ada@example.com',
displayName: 'Example Jira',
@@ -60,6 +60,71 @@ describe('Jira issue operations', () => {
).rejects.toThrow(error.message)
})
it('rejects single-site search failures so the UI can surface them', async () => {
getClientsMock.mockReturnValue([makeEntry('site-1')])
jiraRequestMock.mockRejectedValueOnce(new Error('Forbidden'))
const { searchIssues } = await import('./issues')
await expect(searchIssues('project = ALP', 20, 'site-1')).rejects.toThrow('Forbidden')
})
it('includes Jira status codes in surfaced single-site search failures', async () => {
const error = Object.assign(new Error('Forbidden'), { status: 403 })
getClientsMock.mockReturnValue([makeEntry('site-1')])
jiraRequestMock.mockRejectedValueOnce(error)
const { searchIssues } = await import('./issues')
await expect(searchIssues('project = ALP', 20, 'site-1')).rejects.toThrow(
'Error 403: Forbidden'
)
})
it('keeps healthy sites when one site fails under an "all" search', async () => {
getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')])
jiraRequestMock.mockRejectedValueOnce(new Error('Forbidden')).mockResolvedValueOnce({
issues: [{ id: '1', key: 'BRV-1', fields: { summary: 'Healthy' } }]
})
const { searchIssues } = await import('./issues')
await expect(searchIssues('project = ALP', 20, 'all')).resolves.toMatchObject([
{ key: 'BRV-1', title: 'Healthy' }
])
})
it('keeps healthy sites when the saved selection fans out without an explicit site', async () => {
getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')])
jiraRequestMock.mockRejectedValueOnce(new Error('Forbidden')).mockResolvedValueOnce({
issues: [{ id: '1', key: 'BRV-1', fields: { summary: 'Healthy' } }]
})
const { searchIssues } = await import('./issues')
await expect(searchIssues('project = ALP', 20)).resolves.toMatchObject([
{ key: 'BRV-1', title: 'Healthy' }
])
})
it('surfaces an error when every site fails under an "all" search', async () => {
getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')])
jiraRequestMock
.mockRejectedValueOnce(new Error('Forbidden'))
.mockRejectedValueOnce(new Error('Service Unavailable'))
const { searchIssues } = await import('./issues')
await expect(searchIssues('project = ALP', 20, 'all')).rejects.toThrow('Forbidden')
})
it('prefers operational failures when every "all" search site fails', async () => {
const authError = new Error('Unauthorized')
const operationalError = new Error('Service Unavailable')
getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')])
isAuthErrorMock.mockImplementation((error) => error === authError)
jiraRequestMock.mockRejectedValueOnce(authError).mockRejectedValueOnce(operationalError)
const { searchIssues } = await import('./issues')
await expect(searchIssues('project = ALP', 20, 'all')).rejects.toThrow('Service Unavailable')
expect(clearTokenMock).toHaveBeenCalledWith('site-1')
})
it('paginates Jira project search results before sorting them', async () => {
jiraRequestMock
.mockResolvedValueOnce({
+53 -12
View File
@@ -68,8 +68,38 @@ function clampLimit(limit: number | undefined, fallback = 30): number {
return Math.min(Math.max(1, Number.isFinite(limit) ? Number(limit) : fallback), 100)
}
function shouldThrowAuthError(selection: JiraSiteSelection | null | undefined): boolean {
return selection !== 'all'
type JiraIssueSearchFailure = {
error: unknown
auth: boolean
}
function getErrorStatus(error: unknown): number | null {
if (!error || typeof error !== 'object' || !('status' in error)) {
return null
}
const status = (error as { status?: unknown }).status
return typeof status === 'number' && Number.isFinite(status) ? status : null
}
function toIssueSearchFailureError(error: unknown): unknown {
const status = getErrorStatus(error)
if (
status === null ||
!(error instanceof Error) ||
error.message.startsWith(`Error ${status}:`)
) {
return error
}
return new Error(`Error ${status}: ${error.message}`)
}
function shouldSurfaceSiteFailure(
selection: JiraSiteSelection | null | undefined,
entryCount: number
): boolean {
// getClients can resolve an omitted selection to the persisted 'all' choice;
// multi-entry reads need the same resilient fan-out policy as explicit 'all'.
return selection !== 'all' && entryCount <= 1
}
function asRecord(value: unknown): JiraRecord {
@@ -345,26 +375,37 @@ export async function searchIssues(
return []
}
const safeLimit = clampLimit(limit)
const failures: (JiraIssueSearchFailure | undefined)[] = Array.from({ length: entries.length })
const surfaceSiteFailure = shouldSurfaceSiteFailure(siteId, entries.length)
const results = await Promise.all(
entries.map(async (entry) => {
entries.map(async (entry, index) => {
await acquire()
try {
return await searchIssuesForClient(entry, jql.trim(), safeLimit)
} catch (error) {
if (isAuthError(error)) {
const authFailure = isAuthError(error)
if (authFailure) {
clearToken(entry.site.id)
if (shouldThrowAuthError(siteId)) {
throw error
}
} else {
console.warn('[jira] searchIssues failed:', error)
}
return []
if (surfaceSiteFailure) {
throw toIssueSearchFailureError(error)
}
console.warn('[jira] searchIssues failed:', error)
failures[index] = { error: toIssueSearchFailureError(error), auth: authFailure }
return [] as JiraIssue[]
} finally {
release()
}
})
)
// 'all' fan-out: only surface an error when every connected site failed, so a
// partial success (or a genuinely empty result) is not reported as an error.
const recordedFailures = failures.filter(
(failure): failure is JiraIssueSearchFailure => failure !== undefined
)
if (recordedFailures.length === entries.length) {
throw (recordedFailures.find((failure) => !failure.auth) ?? recordedFailures[0]).error
}
return entries.length === 1
? results.flat().slice(0, safeLimit)
: sortAndLimitIssues(results.flat(), safeLimit)
@@ -388,7 +429,7 @@ export async function getIssue(
} catch (error) {
if (isAuthError(error)) {
clearToken(entry.site.id)
if (shouldThrowAuthError(siteId)) {
if (shouldSurfaceSiteFailure(siteId, entries.length)) {
throw error
}
} else {
@@ -590,7 +631,7 @@ export async function listProjects(siteId?: JiraSiteSelection | null): Promise<J
} catch (error) {
if (isAuthError(error)) {
clearToken(entry.site.id)
if (shouldThrowAuthError(siteId)) {
if (shouldSurfaceSiteFailure(siteId, entries.length)) {
throw error
}
} else {
@@ -0,0 +1,169 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { _resetTracerForTests, setActiveSink, type TracerSink } from './tracer'
import {
_gitSpanSamplingBucketCountForTests,
_resetGitSpanSamplingForTests,
withGitSpan
} from './instrumentation'
type SpanRecord = {
readonly name: string
readonly durationMs: number
readonly attributes: Record<string, unknown>
readonly exit: { readonly _tag: string; readonly cause?: string }
}
type CapturedSink = TracerSink & {
readonly records: SpanRecord[]
}
function isSpanRecord(record: unknown): record is SpanRecord {
return (
record !== null &&
typeof record === 'object' &&
'name' in record &&
typeof record.name === 'string' &&
'durationMs' in record &&
typeof record.durationMs === 'number' &&
'attributes' in record &&
record.attributes !== null &&
typeof record.attributes === 'object' &&
'exit' in record &&
record.exit !== null &&
typeof record.exit === 'object' &&
'_tag' in record.exit &&
typeof record.exit._tag === 'string'
)
}
function makeCapturingSink(): CapturedSink {
const records: SpanRecord[] = []
return {
records,
push(record) {
if (!isSpanRecord(record)) {
throw new Error('expected span record')
}
records.push(record)
},
flush() {
/* no-op */
},
close() {
/* no-op */
}
}
}
let sink: CapturedSink
let nowMs = 1_700_000_000_000
async function runGitSpan(
meta: { args: readonly string[]; cwd?: string },
durationMs: number,
fail = false
) {
vi.setSystemTime(nowMs)
const promise = withGitSpan(meta, async () => {
vi.setSystemTime(nowMs + durationMs)
if (fail) {
throw new Error('git failed')
}
return 'ok'
})
nowMs += durationMs + 1
return await promise
}
beforeEach(() => {
vi.useFakeTimers()
nowMs = 1_700_000_000_000
_resetGitSpanSamplingForTests()
sink = makeCapturingSink()
setActiveSink(sink)
})
afterEach(() => {
vi.useRealTimers()
_resetGitSpanSamplingForTests()
_resetTracerForTests()
})
describe('withGitSpan sampling', () => {
it('bounds fast successful repeated git spans by subcommand and cwd while preserving important spans', async () => {
for (let i = 0; i < 10_000; i++) {
await runGitSpan({ args: ['status', '--short'], cwd: '/repo' }, 5)
}
const repeatedFastSuccesses = sink.records.filter(
(record) =>
record.name === 'git.exec' &&
record.exit._tag === 'Success' &&
record.attributes['git.subcommand'] === 'status' &&
record.attributes.cwd === '/repo' &&
record.durationMs < 250
)
expect(repeatedFastSuccesses.length).toBeGreaterThan(0)
expect(repeatedFastSuccesses.length).toBeLessThan(200)
await expect(runGitSpan({ args: ['status'], cwd: '/repo' }, 5, true)).rejects.toThrow(
'git failed'
)
await runGitSpan({ args: ['status'], cwd: '/repo' }, 275)
await runGitSpan({ args: ['branch'], cwd: '/repo' }, 5)
await runGitSpan({ args: ['status'], cwd: '/other-repo' }, 5)
expect(
sink.records.some(
(record) =>
record.exit._tag === 'Failure' &&
record.attributes['git.subcommand'] === 'status' &&
record.attributes.cwd === '/repo'
)
).toBe(true)
expect(
sink.records.some(
(record) =>
record.exit._tag === 'Success' &&
record.durationMs >= 250 &&
record.attributes['git.subcommand'] === 'status' &&
record.attributes.cwd === '/repo'
)
).toBe(true)
expect(
sink.records.some(
(record) =>
record.exit._tag === 'Success' &&
record.attributes['git.subcommand'] === 'branch' &&
record.attributes.cwd === '/repo'
)
).toBe(true)
expect(
sink.records.some(
(record) =>
record.exit._tag === 'Success' &&
record.attributes['git.subcommand'] === 'status' &&
record.attributes.cwd === '/other-repo'
)
).toBe(true)
})
it('parses git subcommands after global options without changing arg count', async () => {
await runGitSpan({ args: ['-c', 'core.quotePath=false', 'status', '--short'], cwd: '/repo' }, 5)
expect(sink.records[0]?.attributes['git.subcommand']).toBe('status')
expect(sink.records[0]?.attributes['git.arg_count']).toBe(4)
})
it('prunes stale git sampling buckets and caps unique cwd buckets', async () => {
for (let i = 0; i < 700; i++) {
await runGitSpan({ args: ['status'], cwd: `/repo-${i}` }, 5)
}
expect(_gitSpanSamplingBucketCountForTests()).toBeLessThanOrEqual(512)
nowMs += 60_000
await runGitSpan({ args: ['status'], cwd: '/fresh-repo' }, 5)
expect(_gitSpanSamplingBucketCountForTests()).toBe(1)
})
})
+147 -12
View File
@@ -23,29 +23,164 @@
import { withSpan, type ActiveSpan } from './tracer'
const GIT_FAST_SUCCESS_THRESHOLD_MS = 250
const GIT_FAST_SUCCESS_WINDOW_MS = 60_000
const GIT_FAST_SUCCESS_BUDGET_PER_WINDOW = 60
const GIT_SAMPLING_MAX_BUCKETS = 512
// Why: trace captures showed `git status --short` bursts dominating payloads.
// Keep enough fast successes for timing shape while bounding memory and volume.
const GIT_GLOBAL_OPTIONS_WITH_OPERAND = new Set([
'-c',
'-C',
'--git-dir',
'--work-tree',
'--config-env',
'--namespace',
'--exec-path',
'--super-prefix',
'--pathspec-from-file'
])
const GIT_GLOBAL_FLAGS = new Set([
'--bare',
'--no-pager',
'--paginate',
'--literal-pathspecs',
'--glob-pathspecs',
'--noglob-pathspecs',
'--icase-pathspecs',
'--no-optional-locks',
'--pathspec-file-nul'
])
type GitSamplingBucket = {
windowStartMs: number
emitted: number
}
const gitSamplingBuckets = new Map<string, GitSamplingBucket>()
function gitSubcommandFromArgs(args: readonly string[]): string {
for (let index = 0; index < args.length; index++) {
const arg = args[index]
if (!arg) {
continue
}
if (arg === '--') {
return '<none>'
}
if (GIT_GLOBAL_OPTIONS_WITH_OPERAND.has(arg)) {
index += 1
continue
}
if (
arg.startsWith('--git-dir=') ||
arg.startsWith('--work-tree=') ||
arg.startsWith('--config-env=') ||
arg.startsWith('--namespace=') ||
arg.startsWith('--exec-path=') ||
arg.startsWith('--super-prefix=') ||
arg.startsWith('--pathspec-from-file=') ||
(arg.startsWith('-c') && arg.length > 2) ||
(arg.startsWith('-C') && arg.length > 2)
) {
continue
}
if (GIT_GLOBAL_FLAGS.has(arg)) {
continue
}
if (arg.startsWith('-')) {
continue
}
return arg
}
return '<none>'
}
function pruneGitSamplingBuckets(nowMs: number): void {
for (const [key, bucket] of gitSamplingBuckets) {
if (nowMs - bucket.windowStartMs >= GIT_FAST_SUCCESS_WINDOW_MS) {
gitSamplingBuckets.delete(key)
}
}
while (gitSamplingBuckets.size > GIT_SAMPLING_MAX_BUCKETS) {
let oldestKey: string | undefined
let oldestWindowStartMs = Number.POSITIVE_INFINITY
for (const [key, bucket] of gitSamplingBuckets) {
if (bucket.windowStartMs < oldestWindowStartMs) {
oldestKey = key
oldestWindowStartMs = bucket.windowStartMs
}
}
if (oldestKey === undefined) {
return
}
gitSamplingBuckets.delete(oldestKey)
}
}
function gitSamplingKey(meta: GitSpanArgs): string {
return `${gitSubcommandFromArgs(meta.args)}\u0000${meta.cwd ?? '<none>'}`
}
function shouldRecordGitSpan(
meta: GitSpanArgs,
record: { durationMs: number; startTimeUnixNano: string; exit: { _tag: string } }
): boolean {
if (record.exit._tag !== 'Success' || record.durationMs >= GIT_FAST_SUCCESS_THRESHOLD_MS) {
return true
}
const nowMs = Number(BigInt(record.startTimeUnixNano) / 1_000_000n)
pruneGitSamplingBuckets(nowMs)
const key = gitSamplingKey(meta)
const bucket = gitSamplingBuckets.get(key)
if (!bucket) {
gitSamplingBuckets.set(key, { windowStartMs: nowMs, emitted: 1 })
pruneGitSamplingBuckets(nowMs)
return true
}
if (bucket.emitted < GIT_FAST_SUCCESS_BUDGET_PER_WINDOW) {
bucket.emitted += 1
return true
}
return false
}
function addGitAttributes(span: ActiveSpan, meta: GitSpanArgs): void {
span.setAttribute('git.subcommand', gitSubcommandFromArgs(meta.args))
// Why: git args can contain commit messages, branch names, remotes, or
// paths. Keep cardinality without copying user-authored content.
span.setAttribute('git.arg_count', meta.args.length)
if (meta.cwd) {
span.setAttribute('cwd', meta.cwd)
}
}
export function _resetGitSpanSamplingForTests(): void {
gitSamplingBuckets.clear()
}
export function _gitSpanSamplingBucketCountForTests(): number {
return gitSamplingBuckets.size
}
export type GitSpanArgs = {
readonly args: readonly string[]
readonly cwd?: string
}
/** Wrap a git execution in a `git.exec` span. The first argument typically
* is the subcommand (`status`, `clone`, `pull`); promoting it to its own
* attribute makes it grep-friendly without pulling the full args array
* into a single comma-joined string in dashboards. */
/** Wrap a git execution in a `git.exec` span. Git accepts global options before
* the subcommand; promoting the parsed command to its own attribute makes it
* grep-friendly without copying the full args array into dashboards. */
export async function withGitSpan<T>(meta: GitSpanArgs, fn: () => Promise<T>): Promise<T> {
return withSpan(
'git.exec',
async (span) => {
span.setAttribute('git.subcommand', meta.args[0] ?? '<none>')
// Why: git args can contain commit messages, branch names, remotes, or
// paths. Keep cardinality without copying user-authored content.
span.setAttribute('git.arg_count', meta.args.length)
if (meta.cwd) {
span.setAttribute('cwd', meta.cwd)
}
addGitAttributes(span, meta)
return await fn()
},
{ attributes: { kind: 'git' } }
{ attributes: { kind: 'git' }, shouldRecord: (record) => shouldRecordGitSpan(meta, record) }
)
}
+16 -2
View File
@@ -128,10 +128,16 @@ export function getActiveSpanContext(): SpanContext | undefined {
* This is the function 90% of call sites should reach for: it keeps span
* lifetime scoped to the async work it measures.
*/
type SpanRecordDecision = (record: RedactableSpan) => boolean
export async function withSpan<T>(
name: string,
fn: (span: ActiveSpan) => Promise<T> | T,
options?: { kind?: string; attributes?: Record<string, unknown> }
options?: {
kind?: string
attributes?: Record<string, unknown>
shouldRecord?: SpanRecordDecision
}
): Promise<T> {
const span = startSpan(name, options)
try {
@@ -157,7 +163,11 @@ export async function withSpan<T>(
*/
export function startSpan(
name: string,
options?: { kind?: string; attributes?: Record<string, unknown> }
options?: {
kind?: string
attributes?: Record<string, unknown>
shouldRecord?: SpanRecordDecision
}
): ActiveSpan {
if (!activeSink) {
return noopSpan
@@ -203,6 +213,10 @@ export function startSpan(
exit
}
if (options?.shouldRecord && !options.shouldRecord(record)) {
return
}
const redacted = redactSpan(record, 'client')
// Wrap in a `type: 'effect-span'` envelope so the NDJSON file is
// compatible with Effect-style span output. Effect-oriented consumers
+1 -12
View File
@@ -6,6 +6,7 @@ import { isAbsolute, join, posix, win32 } from 'path'
import type { Repo } from '../../shared/types'
import { areWorktreePathsEqual } from '../ipc/worktree-logic'
import Database from '../sqlite/sync-database'
import { columnExists, tableExists } from './schema-helpers'
import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer'
import type {
OpenCodeUsageAttributedEvent,
@@ -135,18 +136,6 @@ async function yieldToEventLoop(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0))
}
function tableExists(db: Database.Database, tableName: string): boolean {
const row = db
.prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(tableName) as { found?: number } | undefined
return row?.found === 1
}
function columnExists(db: Database.Database, tableName: string, columnName: string): boolean {
const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as { name?: string }[]
return rows.some((row) => row.name === columnName)
}
function getProjectJoin(db: Database.Database): string {
return tableExists(db, 'project') && columnExists(db, 'session', 'project_id')
? 'LEFT JOIN project p ON p.id = s.project_id'
+31
View File
@@ -0,0 +1,31 @@
import type SyncDatabase from '../sqlite/sync-database'
// Why: OpenCode's usage scanner and the AI Vault session scanner both need to
// probe the opencode.db schema shape across multiple DB generations. Centralizing
// the probes here avoids two private copies and keeps the contract testable.
type Database = SyncDatabase.Database
/**
* Check whether a table exists in the given SQLite database.
* @param db - A readonly or read-write SyncDatabase instance.
* @param tableName - The table name to look up in sqlite_master.
* @returns `true` if the table exists, `false` otherwise.
*/
export function tableExists(db: Database, tableName: string): boolean {
const row = db
.prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(tableName) as { found?: number } | undefined
return row?.found === 1
}
/**
* Check whether a column exists on a table in the given SQLite database.
* @param db - A readonly or read-write SyncDatabase instance.
* @param tableName - The table to inspect via PRAGMA table_info.
* @param columnName - The column name to find.
* @returns `true` if the column exists on the table, `false` otherwise.
*/
export function columnExists(db: Database, tableName: string, columnName: string): boolean {
const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as { name?: string }[]
return rows.some((row) => row.name === columnName)
}
+3
View File
@@ -478,6 +478,9 @@ describe('Store', () => {
expect(settings.editorAutoSaveDelayMs).toBe(1000)
expect(settings.terminalFontSize).toBe(14)
expect(settings.terminalFontWeight).toBe(500)
expect(settings.terminalScrollSensitivity).toBe(1.15)
expect(settings.terminalFastScrollSensitivity).toBe(5)
expect(settings.terminalTuiScrollSensitivity).toBe(3)
expect(settings.terminalUseSeparateLightTheme).toBe(true)
expect(settings.rightSidebarOpenByDefault).toBe(true)
expect(settings.showTasksButton).toBe(true)
@@ -227,6 +227,18 @@ describe('SshPtyProvider', () => {
expect(mux.request).toHaveBeenCalledWith('pty.attach', { id: 'pty-1' })
})
it('attachForReconnect returns replay without relay notification', async () => {
mux.request.mockResolvedValue({ replay: 'restored output' })
const result = await provider.attachForReconnect(scopedPty1)
expect(result).toEqual({ replay: 'restored output' })
expect(mux.request).toHaveBeenCalledWith('pty.attach', {
id: 'pty-1',
suppressReplayNotification: true
})
})
it('write sends pty.data notification', () => {
provider.write(scopedPty1, 'hello')
expect(mux.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: 'hello' })
+10
View File
@@ -177,6 +177,16 @@ export class SshPtyProvider implements IPtyProvider {
await this.mux.request('pty.attach', { id: this.toRelayPtyId(id) })
}
async attachForReconnect(id: string): Promise<{ replay?: string }> {
// Why: reconnect owns replay delivery so stale/duplicate attach results can
// be filtered before they reach the renderer.
const result = (await this.mux.request('pty.attach', {
id: this.toRelayPtyId(id),
suppressReplayNotification: true
})) as { replay?: string } | undefined
return result ?? {}
}
write(id: string, data: string): void {
this.mux.notify('pty.data', { id: this.toRelayPtyId(id), data })
}
@@ -89,6 +89,17 @@ describe('resolveWindowsShellLaunchArgs', () => {
expect(command).not.toContain('`e]133')
})
it('normalizes MSYS drive cwd before spawning native PowerShell', () => {
const result = resolveWindowsShellLaunchArgs(
'powershell.exe',
'/c/Users/alice/project',
'C:\\Users\\alice'
)
expect(result.effectiveCwd).toBe('C:\\Users\\alice\\project')
expect(result.validationCwd).toBe('C:\\Users\\alice\\project')
})
it('embeds short PowerShell startup commands after the OSC 133 bootstrap', () => {
const result = resolveWindowsShellLaunchArgs(
'powershell.exe',
@@ -189,6 +200,33 @@ describe('resolveWindowsShellLaunchArgs', () => {
expect(result.validationCwd).toBe('C:\\Users\\alice\\code')
})
it('translates MSYS drive cwd to /mnt/<drive>/... for wsl.exe', () => {
const result = resolveWindowsShellLaunchArgs(
'wsl.exe',
'/c/Users/alice/project',
'C:\\Users\\alice',
undefined,
'codex'
)
expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/project'))
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
expect(result.validationCwd).toBe('C:\\Users\\alice\\project')
})
it('does not treat MSYS drive cwd as a WSL POSIX cwd', () => {
const result = resolveWindowsShellLaunchArgs(
'wsl.exe',
'/c/Users/alice/project',
'C:\\Users\\alice',
{ distro: 'Ubuntu', treatPosixCwdAsWsl: true }
)
expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/project', 'Ubuntu'))
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
expect(result.validationCwd).toBe('C:\\Users\\alice\\project')
})
it('escapes single quotes when translating a WSL cwd', () => {
const result = resolveWindowsShellLaunchArgs('wsl.exe', "C:\\weird'path", 'C:\\Users\\alice')
// The injected sh cmd must not break out of the surrounding single quotes
+27 -12
View File
@@ -105,6 +105,19 @@ function buildWslShellArgs(linuxCwd: string, distro?: string): string[] {
return distro ? ['-d', distro, ...shellArgs] : shellArgs
}
function normalizeMsysDrivePath(cwd: string): string {
const match = cwd.match(/^\/([A-Za-z])(?:\/(.*))?$/)
if (!match) {
return cwd
}
// Why: Git Bash/MSYS launch contexts can hand Windows terminals `/d/repo`;
// ConPTY requires the equivalent native drive path as its cwd.
const driveLetter = match[1].toUpperCase()
const rest = match[2]?.replace(/\//g, '\\') ?? ''
return rest ? `${driveLetter}:\\${rest}` : `${driveLetter}:\\`
}
/** Build the argv + effective cwd for a Windows shell launch.
*
* - cmd.exe: `/K chcp 65001 > nul` so multi-byte CJK output renders correctly.
@@ -122,6 +135,8 @@ export function resolveWindowsShellLaunchArgs(
startupCommand?: string
): WindowsShellLaunchArgs {
const shellBasename = pathWin32.basename(shellPath).toLowerCase()
const nativeCwd = normalizeMsysDrivePath(cwd)
const isMsysDriveCwd = nativeCwd !== cwd
if (shellBasename === 'cmd.exe') {
const shellArgStartupCommand = getCmdShellArgStartupCommand(startupCommand)
@@ -133,8 +148,8 @@ export function resolveWindowsShellLaunchArgs(
: CMD_UTF8_SETUP_COMMAND
],
...(shellArgStartupCommand ? { startupCommandDeliveredInShellArgs: true } : {}),
effectiveCwd: cwd,
validationCwd: cwd
effectiveCwd: nativeCwd,
validationCwd: nativeCwd
}
}
@@ -147,16 +162,16 @@ export function resolveWindowsShellLaunchArgs(
...(powerShellCommand.startupCommandDeliveredInShellArgs
? { startupCommandDeliveredInShellArgs: true }
: {}),
effectiveCwd: cwd,
validationCwd: cwd
effectiveCwd: nativeCwd,
validationCwd: nativeCwd
}
}
if (isWindowsGitBashShellPath(shellPath)) {
return {
shellArgs: ['--login', '-i'],
effectiveCwd: cwd,
validationCwd: cwd
effectiveCwd: nativeCwd,
validationCwd: nativeCwd
}
}
@@ -169,25 +184,25 @@ export function resolveWindowsShellLaunchArgs(
validationCwd: cwd
}
}
if (wslContext?.treatPosixCwdAsWsl && cwd.startsWith('/')) {
if (wslContext?.treatPosixCwdAsWsl && cwd.startsWith('/') && !isMsysDriveCwd) {
return {
shellArgs: buildWslShellArgs(cwd, wslContext.distro),
effectiveCwd: defaultCwd,
validationCwd: toWindowsWslPath(cwd, wslContext.distro)
}
}
const driveMatch = cwd.replace(/\\/g, '/').match(/^([A-Za-z]):\/?(.*)$/)
const linuxCwd = driveMatch ? toLinuxPath(cwd) : '/mnt/c'
const driveMatch = nativeCwd.replace(/\\/g, '/').match(/^([A-Za-z]):\/?(.*)$/)
const linuxCwd = driveMatch ? toLinuxPath(nativeCwd) : '/mnt/c'
return {
shellArgs: buildWslShellArgs(linuxCwd, wslContext?.distro),
effectiveCwd: defaultCwd,
validationCwd: cwd
validationCwd: nativeCwd
}
}
return {
shellArgs: [],
effectiveCwd: cwd,
validationCwd: cwd
effectiveCwd: nativeCwd,
validationCwd: nativeCwd
}
}
+54 -19
View File
@@ -169,7 +169,7 @@ describe('fetchClaudeRateLimits', () => {
)
})
it('uses legacy Keychain credentials for host system default without an explicit config dir', async () => {
it('falls back to legacy Keychain credentials for host system default without an explicit config dir', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
@@ -178,22 +178,16 @@ describe('fetchClaudeRateLimits', () => {
stripAuthEnv: false,
provenance: 'system'
}
vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValueOnce(
JSON.stringify({
claudeAiOauth: {
accessToken: 'legacy-oauth-token',
expiresAt: Date.now() + 60_000
}
})
)
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue(
JSON.stringify({
claudeAiOauth: {
accessToken: 'stale-scoped-oauth-token',
expiresAt: Date.now() + 60_000
}
})
)
vi.mocked(readActiveClaudeKeychainCredentialsStrict)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(
JSON.stringify({
claudeAiOauth: {
accessToken: 'legacy-oauth-token',
expiresAt: Date.now() + 60_000
}
})
)
await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({
provider: 'claude',
@@ -202,8 +196,9 @@ describe('fetchClaudeRateLimits', () => {
weekly: { usedPercent: 34 }
})
expect(readActiveClaudeKeychainCredentials).toHaveBeenCalledWith(undefined)
expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalled()
expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(1, configDir)
expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2, undefined)
expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled()
expect(netFetchMock).toHaveBeenCalledWith(
'https://api.anthropic.com/api/oauth/usage',
expect.objectContaining({
@@ -214,6 +209,46 @@ describe('fetchClaudeRateLimits', () => {
)
})
it('reads scoped Keychain credentials for host system default without an explicit config dir', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
runtime: 'host',
envPatch: {},
stripAuthEnv: false,
provenance: 'system'
}
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce(
JSON.stringify({
claudeAiOauth: {
accessToken: 'scoped-oauth-token',
expiresAt: Date.now() + 60_000
}
})
)
await expect(
fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false })
).resolves.toMatchObject({
provider: 'claude',
status: 'ok',
session: { usedPercent: 12 },
weekly: { usedPercent: 34 }
})
expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(configDir)
expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled()
expect(fetchViaPty).not.toHaveBeenCalled()
expect(netFetchMock).toHaveBeenCalledWith(
'https://api.anthropic.com/api/oauth/usage',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer scoped-oauth-token'
})
})
)
})
it('falls back to the credentials file when Keychain access fails', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
+4 -6
View File
@@ -249,13 +249,11 @@ function resolveOAuthCredentialReadOptions(
if (!authPreparation) {
return undefined
}
// Why: Claude Code 2.1+ can scope even the default config dir's macOS
// Keychain item. Try scoped first, with legacy still handled as fallback.
const readOptions: OAuthCredentialReadOptions = {
credentialsFileConfigDir: authPreparation.configDir
}
// Why: host system-default launches do not inject CLAUDE_CONFIG_DIR, so
// their Keychain lookup must mirror Claude's legacy service ordering.
if (authPreparation.envPatch.CLAUDE_CONFIG_DIR) {
readOptions.keychainConfigDir = authPreparation.configDir
credentialsFileConfigDir: authPreparation.configDir,
keychainConfigDir: authPreparation.configDir
}
return readOptions
}
+62 -1
View File
@@ -353,7 +353,7 @@ describe('RateLimitService', () => {
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
authPreparation: undefined,
allowPtyFallback: true
allowPtyFallback: false
})
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1)
expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1)
@@ -464,6 +464,67 @@ describe('RateLimitService', () => {
expect(service.getState().claudeTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
})
it('does not use Claude PTY fallback for system-default usage refreshes', async () => {
const service = new RateLimitService()
service.setClaudeAuthPreparationResolver(async () => ({
configDir: '/tmp/.claude',
runtime: 'host',
wslDistro: null,
wslLinuxConfigDir: null,
envPatch: {},
stripAuthEnv: false,
provenance: 'system'
}))
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now()))
await service.refresh()
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
authPreparation: expect.objectContaining({ provenance: 'system' }),
allowPtyFallback: false
})
})
it('does not use Claude PTY fallback when Claude auth preparation is unavailable', async () => {
const service = new RateLimitService()
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now()))
await service.refresh()
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
authPreparation: undefined,
allowPtyFallback: false
})
})
it('does not use Claude PTY fallback for WSL system-default usage refreshes', async () => {
const service = new RateLimitService()
service.setClaudeFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' })
service.setClaudeAuthPreparationResolver(async () => ({
configDir: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.claude',
runtime: 'wsl',
wslDistro: 'Ubuntu',
wslLinuxConfigDir: '/home/jin/.claude',
envPatch: {},
stripAuthEnv: true,
provenance: 'wsl:Ubuntu:system'
}))
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now()))
await service.refresh()
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
authPreparation: expect.objectContaining({ provenance: 'wsl:Ubuntu:system' }),
allowPtyFallback: false
})
})
it('does not cache host Codex usage under an outgoing WSL account', async () => {
const service = new RateLimitService()
const wslCodexHome =
+24 -4
View File
@@ -72,6 +72,18 @@ function normalizePollingInterval(ms: number): number {
return Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, ms))
}
function isSystemDefaultClaudeAuth(
authPreparation: ClaudeRuntimeAuthPreparation | undefined
): boolean {
// Why: fetch cycles classify missing Claude auth as system-default; keep the
// PTY fallback gate aligned so background refresh cannot trigger auth flows.
if (!authPreparation) {
return true
}
const provenance = authPreparation?.provenance
return provenance === 'system' || Boolean(provenance?.endsWith(':system'))
}
export class RateLimitService {
private state: InternalRateLimitState = {
claude: null,
@@ -792,10 +804,18 @@ export class RateLimitService {
return process.platform !== 'win32'
}
private shouldAllowClaudePtyFallback(): boolean {
private shouldAllowClaudePtyFallback(
authPreparation: ClaudeRuntimeAuthPreparation | undefined
): boolean {
// Why: automatic recovery uses Claude CLI as the next source, but Windows
// hidden PTY support remains less reliable than host/WSL shells.
return process.platform !== 'win32'
if (process.platform === 'win32') {
return false
}
// Why: system-default Claude is not an Orca-managed account. Background
// quota refresh may read existing OAuth, but must not launch Claude and
// trigger auth/browser flows for users who never configured Claude in Orca.
return !isSystemDefaultClaudeAuth(authPreparation)
}
private withFetchingStatus(
@@ -861,7 +881,7 @@ export class RateLimitService {
await Promise.allSettled([
fetchClaudeRateLimits({
authPreparation: claudeAuthPreparation,
allowPtyFallback: this.shouldAllowClaudePtyFallback()
allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation)
}),
missingWslCodexHome ??
fetchCodexRateLimits({
@@ -1036,7 +1056,7 @@ export class RateLimitService {
const claude = await fetchClaudeRateLimits({
authPreparation: claudeAuthPreparation,
allowPtyFallback: this.shouldAllowClaudePtyFallback()
allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation)
}).catch(
(err): ProviderRateLimits => ({
provider: 'claude',
+14 -14
View File
@@ -164,7 +164,7 @@ const {
addGitHubIssueCommentMock,
listGitHubLabelsMock,
listGitHubAssignableUsersMock,
detectInstalledAgentsMock,
detectInstalledAgentsWithShellPathHydrationMock,
detectRemoteAgentsMock,
listGitLabMergeRequestsMock,
listGitLabWorkItemsMock,
@@ -255,7 +255,7 @@ const {
addGitHubIssueCommentMock: vi.fn(),
listGitHubLabelsMock: vi.fn(),
listGitHubAssignableUsersMock: vi.fn(),
detectInstalledAgentsMock: vi.fn(),
detectInstalledAgentsWithShellPathHydrationMock: vi.fn(),
detectRemoteAgentsMock: vi.fn(),
listGitLabMergeRequestsMock: vi.fn(),
listGitLabWorkItemsMock: vi.fn(),
@@ -319,7 +319,7 @@ vi.mock('../ipc/ssh', () => ({
}))
vi.mock('../ipc/preflight', () => ({
detectInstalledAgents: detectInstalledAgentsMock,
detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock,
detectRemoteAgents: detectRemoteAgentsMock
}))
@@ -620,8 +620,8 @@ function resetRuntimeTestMocks(): void {
listGitHubLabelsMock.mockResolvedValue([])
listGitHubAssignableUsersMock.mockReset()
listGitHubAssignableUsersMock.mockResolvedValue([])
detectInstalledAgentsMock.mockReset()
detectInstalledAgentsMock.mockResolvedValue([])
detectInstalledAgentsWithShellPathHydrationMock.mockReset()
detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue([])
detectRemoteAgentsMock.mockReset()
detectRemoteAgentsMock.mockResolvedValue([])
listGitLabMergeRequestsMock.mockReset()
@@ -17898,7 +17898,7 @@ describe('OrcaRuntimeService', () => {
})
it('uses desktop task agent selection and bracketed-pastes startup drafts for local worktrees', async () => {
detectInstalledAgentsMock.mockResolvedValue(['claude'])
detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue(['claude'])
const metaById: Record<string, WorktreeMeta> = {}
const runtimeStore = {
...store,
@@ -17959,7 +17959,7 @@ describe('OrcaRuntimeService', () => {
activate: true
})
expect(detectInstalledAgentsMock).not.toHaveBeenCalled()
expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled()
expect(detectRemoteAgentsMock).not.toHaveBeenCalled()
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({
@@ -18215,7 +18215,7 @@ describe('OrcaRuntimeService', () => {
})
it('records the resolved fallback agent when the requested startup draft agent is disabled', async () => {
detectInstalledAgentsMock.mockResolvedValue(['claude'])
detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue(['claude'])
const metaById: Record<string, WorktreeMeta> = {}
const runtimeStore = {
...store,
@@ -18276,7 +18276,7 @@ describe('OrcaRuntimeService', () => {
activate: true
})
expect(detectInstalledAgentsMock).toHaveBeenCalled()
expect(detectInstalledAgentsWithShellPathHydrationMock).toHaveBeenCalled()
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({
cwd: '/tmp/workspaces/runtime-fallback-draft',
@@ -18405,7 +18405,7 @@ describe('OrcaRuntimeService', () => {
})
it('lets explicit startup draft agents override the desktop default', async () => {
detectInstalledAgentsMock.mockResolvedValue([])
detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue([])
const metaById: Record<string, WorktreeMeta> = {}
const runtimeStore = {
...store,
@@ -18467,7 +18467,7 @@ describe('OrcaRuntimeService', () => {
activate: true
})
expect(detectInstalledAgentsMock).not.toHaveBeenCalled()
expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled()
expect(detectRemoteAgentsMock).not.toHaveBeenCalled()
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({
@@ -18485,7 +18485,7 @@ describe('OrcaRuntimeService', () => {
})
it('does not auto-launch an agent for startup drafts when the default is blank', async () => {
detectInstalledAgentsMock.mockResolvedValue(['claude', 'codex'])
detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue(['claude', 'codex'])
const metaById: Record<string, WorktreeMeta> = {}
const runtimeStore = {
...store,
@@ -18545,7 +18545,7 @@ describe('OrcaRuntimeService', () => {
activate: true
})
expect(detectInstalledAgentsMock).not.toHaveBeenCalled()
expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled()
expect(detectRemoteAgentsMock).not.toHaveBeenCalled()
expect(spawn).not.toHaveBeenCalled()
expect(metaById[result.worktree.id]?.createdWithAgent).toBeUndefined()
@@ -18638,7 +18638,7 @@ describe('OrcaRuntimeService', () => {
})
expect(detectRemoteAgentsMock).toHaveBeenCalledWith({ connectionId: 'ssh-1' })
expect(detectInstalledAgentsMock).not.toHaveBeenCalled()
expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled()
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({
cwd: '/remote/mobile-startup-draft',
+3 -2
View File
@@ -172,7 +172,7 @@ import {
resolveTuiAgentLaunchEnv
} from '../../shared/tui-agent-launch-defaults'
import { isTuiAgent, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
import { detectInstalledAgents, detectRemoteAgents } from '../ipc/preflight'
import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents } from '../ipc/preflight'
import {
markCodexProjectTrusted,
markCopilotFolderTrusted,
@@ -11436,9 +11436,10 @@ export class OrcaRuntimeService {
if (!agent) {
let detected: string[] = []
try {
// Why: startup-draft fallback can run from sparse runtime launch envs too.
detected = repo.connectionId
? await detectRemoteAgents({ connectionId: repo.connectionId })
: await detectInstalledAgents()
: await detectInstalledAgentsWithShellPathHydration()
} catch {
detected = []
}
@@ -5,19 +5,19 @@ import type { OrcaRuntimeService } from '../../orca-runtime'
import { PREFLIGHT_METHODS } from './preflight'
const {
detectInstalledAgentsMock,
detectInstalledAgentsWithShellPathHydrationMock,
detectRemoteAgentsMock,
refreshShellPathAndDetectAgentsMock,
runPreflightCheckMock
} = vi.hoisted(() => ({
detectInstalledAgentsMock: vi.fn(),
detectInstalledAgentsWithShellPathHydrationMock: vi.fn(),
detectRemoteAgentsMock: vi.fn(),
refreshShellPathAndDetectAgentsMock: vi.fn(),
runPreflightCheckMock: vi.fn()
}))
vi.mock('../../../ipc/preflight', () => ({
detectInstalledAgents: detectInstalledAgentsMock,
detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock,
detectRemoteAgents: detectRemoteAgentsMock,
refreshShellPathAndDetectAgents: refreshShellPathAndDetectAgentsMock,
runPreflightCheck: runPreflightCheckMock
@@ -46,7 +46,7 @@ describe('preflight RPC methods', () => {
})
it('detects agents and refreshes PATH on the server through runtime RPC', async () => {
detectInstalledAgentsMock.mockResolvedValueOnce(['codex'])
detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValueOnce(['codex'])
refreshShellPathAndDetectAgentsMock.mockResolvedValueOnce({
agents: ['codex', 'claude'],
addedPathSegments: ['/opt/bin'],
@@ -60,7 +60,7 @@ describe('preflight RPC methods', () => {
const detected = await dispatcher.dispatch(makeRequest('preflight.detectAgents'))
const refreshed = await dispatcher.dispatch(makeRequest('preflight.refreshAgents'))
expect(detectInstalledAgentsMock).toHaveBeenCalled()
expect(detectInstalledAgentsWithShellPathHydrationMock).toHaveBeenCalled()
expect(refreshShellPathAndDetectAgentsMock).toHaveBeenCalled()
expect(detected).toMatchObject({ ok: true, result: ['codex'] })
expect(refreshed).toMatchObject({
+2 -2
View File
@@ -2,7 +2,7 @@ import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import {
detectRemoteAgents,
detectInstalledAgents,
detectInstalledAgentsWithShellPathHydration,
refreshShellPathAndDetectAgents,
runPreflightCheck
} from '../../../ipc/preflight'
@@ -23,7 +23,7 @@ export const PREFLIGHT_METHODS: RpcMethod[] = [
defineMethod({
name: 'preflight.detectAgents',
params: null,
handler: async () => detectInstalledAgents()
handler: async () => detectInstalledAgentsWithShellPathHydration()
}),
defineMethod({
name: 'preflight.detectRemoteAgents',
+58 -8
View File
@@ -48,6 +48,7 @@ vi.mock('../providers/ssh-pty-provider', () => ({
onReplay = vi.fn().mockReturnValue(() => {})
onExit = vi.fn().mockReturnValue(() => {})
attach = vi.fn().mockResolvedValue(undefined)
attachForReconnect = vi.fn().mockResolvedValue({})
dispose = vi.fn()
}
}))
@@ -67,7 +68,8 @@ vi.mock('../ipc/pty', () => ({
unregisterSshPtyProvider: vi.fn(),
getSshPtyProvider: vi.fn().mockReturnValue({
dispose: vi.fn(),
attach: vi.fn().mockResolvedValue(undefined)
attach: vi.fn().mockResolvedValue(undefined),
attachForReconnect: vi.fn().mockResolvedValue({})
}),
getPtyIdsForConnection: vi.fn().mockReturnValue([]),
clearPtyOwnershipForConnection: vi.fn(),
@@ -355,7 +357,7 @@ describe('SshRelaySession', () => {
const { getSshPtyProvider } = await import('../ipc/pty')
const mockAttach = vi.fn().mockResolvedValue(undefined)
vi.mocked(getSshPtyProvider).mockReturnValue({
attach: mockAttach,
attachForReconnect: mockAttach,
dispose: vi.fn()
} as unknown as ReturnType<typeof getSshPtyProvider>)
vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1', 'pty-2'])
@@ -366,12 +368,60 @@ describe('SshRelaySession', () => {
expect(mockAttach).toHaveBeenCalledWith('pty-2')
})
it('forwards reconnect replay after the attach attempt is still current', async () => {
const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps()
const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward)
await session.establish(mockConn)
vi.clearAllMocks()
mockDeploySuccess()
const { getSshPtyProvider } = await import('../ipc/pty')
const mockAttach = vi.fn().mockResolvedValue({ replay: 'restored-output' })
vi.mocked(getSshPtyProvider).mockReturnValue({
attachForReconnect: mockAttach,
dispose: vi.fn()
} as unknown as ReturnType<typeof getSshPtyProvider>)
vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1'])
await session.reconnect(mockConn)
expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:replay', {
id: 'ssh:target-1@@pty-1',
data: 'restored-output'
})
})
it('drops identical reconnect replay payloads inside one reconnect burst', async () => {
const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps()
const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward)
await session.establish(mockConn)
vi.clearAllMocks()
mockDeploySuccess()
const { getSshPtyProvider } = await import('../ipc/pty')
const mockAttach = vi.fn().mockResolvedValue({ replay: 'same-output' })
vi.mocked(getSshPtyProvider).mockReturnValue({
attachForReconnect: mockAttach,
dispose: vi.fn()
} as unknown as ReturnType<typeof getSshPtyProvider>)
vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1'])
await session.reconnect(mockConn)
await session.reconnect(mockConn)
const replaySends = vi
.mocked(mockWindow.webContents.send)
.mock.calls.filter(([channel]) => channel === 'pty:replay')
expect(mockAttach).toHaveBeenCalledTimes(2)
expect(replaySends).toHaveLength(1)
})
it('establish re-attaches owned PTYs after explicit disconnect', async () => {
const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps()
const { getSshPtyProvider } = await import('../ipc/pty')
const mockAttach = vi.fn().mockResolvedValue(undefined)
vi.mocked(getSshPtyProvider).mockReturnValue({
attach: mockAttach,
attachForReconnect: mockAttach,
dispose: vi.fn()
} as unknown as ReturnType<typeof getSshPtyProvider>)
vi.mocked(getPtyIdsForConnection).mockReturnValue(['ssh:target-1@@pty-1'])
@@ -390,7 +440,7 @@ describe('SshRelaySession', () => {
const { getSshPtyProvider } = await import('../ipc/pty')
const mockAttach = vi.fn().mockResolvedValue(undefined)
vi.mocked(getSshPtyProvider).mockReturnValue({
attach: mockAttach,
attachForReconnect: mockAttach,
dispose: vi.fn()
} as unknown as ReturnType<typeof getSshPtyProvider>)
vi.mocked(getPtyIdsForConnection).mockReturnValue([])
@@ -419,7 +469,7 @@ describe('SshRelaySession', () => {
})
)
vi.mocked(getSshPtyProvider).mockReturnValue({
attach: mockAttach,
attachForReconnect: mockAttach,
dispose: vi.fn()
} as unknown as ReturnType<typeof getSshPtyProvider>)
vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1'])
@@ -454,7 +504,7 @@ describe('SshRelaySession', () => {
})
)
vi.mocked(getSshPtyProvider).mockReturnValue({
attach: mockAttach,
attachForReconnect: mockAttach,
dispose: vi.fn()
} as unknown as ReturnType<typeof getSshPtyProvider>)
vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1'])
@@ -486,7 +536,7 @@ describe('SshRelaySession', () => {
.mockRejectedValueOnce(new Error('PTY "pty-stale" not found'))
.mockResolvedValueOnce(undefined)
vi.mocked(getSshPtyProvider).mockReturnValue({
attach: mockAttach,
attachForReconnect: mockAttach,
dispose: vi.fn()
} as unknown as ReturnType<typeof getSshPtyProvider>)
vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-stale', 'pty-live'])
@@ -515,7 +565,7 @@ describe('SshRelaySession', () => {
const { getSshPtyProvider } = await import('../ipc/pty')
const mockAttach = vi.fn().mockRejectedValue(new Error('Multiplexer disposed'))
vi.mocked(getSshPtyProvider).mockReturnValue({
attach: mockAttach,
attachForReconnect: mockAttach,
dispose: vi.fn()
} as unknown as ReturnType<typeof getSshPtyProvider>)
vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-live'])

Some files were not shown because too many files have changed in this diff Show More