Merge remote-tracking branch 'origin/main' into brennanb2025/fix-permissions-query-status

This commit is contained in:
Brennan Benson
2026-08-16 19:56:35 -07:00
2700 changed files with 485938 additions and 365091 deletions
+8 -1
View File
@@ -17,6 +17,7 @@
Fixes #
## Visual Proof
<!-- REQUIRED for UI / behavior changes. Please attach a BEFORE and AFTER that can easily tabbed/switched. Use videos for when appropriate over screenshots -->
<!-- If there is truly no visual or interaction change, write exactly: `N/A` and briefly say why. -->
<!-- For attachments NEVER add directly to the PR files (do not commit to files), use `gh image` extension or drag + drop (works for any attachment) -->
@@ -28,13 +29,19 @@ Fixes #
- [ ] I manually tested these changes locally
- [ ] Automated tests added/updated, or explained why not below
## AI Disclosure
<!-- DO NOT FILL IN IF YOU ARE STABLYAI TEAM MEMBER (INTERNAL CONTRIBUTOR), IGNORE SECTION: -->
<!-- Which AI model if anyone was used, please state the details -->
## Review
## Agent skill upstream boundary
- [ ] Not applicable, or this change follows `docs/reference/agent-skill-sharing-upstream-boundary.md` and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.
## Notes
Ensure no issues in: Security, Cross-platoform support (Linux, Windows, Mac), Remote SSH, Mobile, general backwards compatibility, performance
## Checklist
+228
View File
@@ -0,0 +1,228 @@
import { appendFileSync, readFileSync } from 'node:fs'
import { pathToFileURL } from 'node:url'
import { mergeLocBlock, renderLocBlock, sumChangedFiles } from './pr-test-loc-table.mjs'
export const PR_FILES_PAGE_LIMIT = 3000
export function nextLink(linkHeader) {
if (linkHeader == null || linkHeader.length === 0) {
return undefined
}
for (const part of linkHeader.split(',')) {
const match = part.match(/<([^>]+)>\s*;\s*rel="next"/)
if (match != null) {
return match[1]
}
}
return undefined
}
function githubHeaders(token) {
return {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
'User-Agent': 'orca-pr-test-loc',
'X-GitHub-Api-Version': '2022-11-28'
}
}
export async function listPullFiles({ owner, repo, pullNumber, token, fetchImpl = fetch }) {
const files = []
let url = `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/files?per_page=100`
while (url != null) {
const response = await fetchImpl(url, { headers: githubHeaders(token) })
if (!response.ok) {
throw new Error(
`Failed to list PR #${pullNumber} files: ${response.status} ${response.statusText}`
)
}
const page = await response.json()
if (!Array.isArray(page)) {
throw new Error(`Unexpected PR files payload for #${pullNumber}`)
}
files.push(...page)
if (files.length >= PR_FILES_PAGE_LIMIT) {
console.log(
`PR #${pullNumber} file list hit GitHub's ${PR_FILES_PAGE_LIMIT}-file cap; totals may be short.`
)
return files.slice(0, PR_FILES_PAGE_LIMIT)
}
url = nextLink(response.headers.get('link'))
}
return files
}
function writeGithubOutput(totals) {
const block = `${renderLocBlock(totals)}\n`
const outputPath = process.env.GITHUB_OUTPUT
if (outputPath != null) {
appendFileSync(outputPath, `summary<<ORCA_PR_LOC_EOF\n${block}ORCA_PR_LOC_EOF\n`)
}
const summaryPath = process.env.GITHUB_STEP_SUMMARY
if (summaryPath != null) {
appendFileSync(summaryPath, `${block}\n`)
}
}
async function updatePullRequest({ owner, repo, pullNumber, token, totals, fetchImpl = fetch }) {
const headers = githubHeaders(token)
const url = `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}`
const response = await fetchImpl(url, { headers })
if (response.status === 403) {
console.log('Skipping PR body update: token cannot write (likely a fork PR).')
return 0
}
if (!response.ok) {
throw new Error(`Failed to read PR #${pullNumber}: ${response.status} ${response.statusText}`)
}
const pull = await response.json()
const nextBody = mergeLocBlock(pull.body, totals)
if (nextBody === (pull.body ?? '')) {
console.log(`PR #${pullNumber} LoC header already current.`)
return 0
}
const update = await fetchImpl(url, {
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json'
},
body: JSON.stringify({ body: nextBody })
})
if (update.status === 403) {
console.log('Skipping PR body update: token cannot write (likely a fork PR).')
return 0
}
if (!update.ok) {
throw new Error(`Failed to update PR #${pullNumber}: ${update.status} ${update.statusText}`)
}
console.log(`Updated LoC header on PR #${pullNumber}.`)
return 0
}
function resolveRepository() {
const repository = process.env.GITHUB_REPOSITORY
if (repository == null || !repository.includes('/')) {
return undefined
}
const slash = repository.indexOf('/')
return { owner: repository.slice(0, slash), repo: repository.slice(slash + 1) }
}
function resolveToken() {
return process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN
}
function printUsage() {
console.error(
`Usage: ${process.argv[1]} --from-pr <number> | --update-pr <number> [--files-json <file>] [--merge-body <file>]`
)
}
async function main(argv) {
let filesJsonPath
let mergeBodyPath
let fromPrNumber
let updatePrNumber
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]
if (arg === '--files-json') {
filesJsonPath = argv[i + 1]
i += 1
continue
}
if (arg === '--merge-body') {
mergeBodyPath = argv[i + 1]
i += 1
continue
}
if (arg === '--from-pr') {
fromPrNumber = argv[i + 1]
i += 1
continue
}
if (arg === '--update-pr') {
updatePrNumber = argv[i + 1]
i += 1
continue
}
printUsage()
return 2
}
const pullNumber = updatePrNumber ?? fromPrNumber
if (
(argv.includes('--files-json') && filesJsonPath == null) ||
(argv.includes('--merge-body') && mergeBodyPath == null) ||
(argv.includes('--from-pr') && fromPrNumber == null) ||
(argv.includes('--update-pr') && updatePrNumber == null) ||
(filesJsonPath == null && pullNumber == null)
) {
printUsage()
return 2
}
let files
if (filesJsonPath != null) {
files = JSON.parse(readFileSync(filesJsonPath, 'utf8'))
} else {
const repository = resolveRepository()
const token = resolveToken()
if (repository == null || token == null) {
console.error('GITHUB_REPOSITORY and GITHUB_TOKEN are required to read a pull request.')
return 2
}
files = await listPullFiles({
...repository,
pullNumber: Number(pullNumber),
token
})
}
const totals = sumChangedFiles(files)
writeGithubOutput(totals)
if (mergeBodyPath != null) {
process.stdout.write(mergeLocBlock(readFileSync(mergeBodyPath, 'utf8'), totals))
return 0
}
console.log(renderLocBlock(totals))
if (updatePrNumber == null) {
return 0
}
const repository = resolveRepository()
const token = resolveToken()
if (repository == null || token == null) {
console.error('GITHUB_REPOSITORY and GITHUB_TOKEN are required with --update-pr.')
return 2
}
return updatePullRequest({
...repository,
pullNumber: Number(updatePrNumber),
token,
totals
})
}
if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) {
main(process.argv.slice(2))
.then((code) => {
process.exitCode = code
})
.catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
}
+94
View File
@@ -0,0 +1,94 @@
export const LOC_BLOCK_START = '<!-- orca-pr-loc -->'
export const LOC_BLOCK_END = '<!-- /orca-pr-loc -->'
export const LOC_HANDS_OFF_COMMENT =
'<!-- Programmatic LoC summary. Do not edit by hand; rewritten on every commit. -->'
const TEST_DIR_SEGMENT = /(?:^|\/)(?:__tests__|e2e|tests)(?:\/|$)/i
const TEST_FILENAME = /\.(?:test|spec|e2e)\.[^/]+$/i
export function isTestPath(path) {
const normalized = path.replaceAll('\\', '/')
return TEST_DIR_SEGMENT.test(normalized) || TEST_FILENAME.test(normalized)
}
export function emptyLocTotals() {
return {
test: { files: 0, added: 0, deleted: 0 },
nonTest: { files: 0, added: 0, deleted: 0 }
}
}
export function sumChangedFiles(files) {
const totals = emptyLocTotals()
for (const file of files) {
const path = file.filename
if (path == null) {
continue
}
const bucket = isTestPath(path) ? totals.test : totals.nonTest
bucket.files += 1
bucket.added += Number(file.additions ?? 0)
bucket.deleted += Number(file.deletions ?? 0)
}
return totals
}
const COLOR_ADDED = '#1a7f37'
const COLOR_DELETED = '#cf222e'
function diffCell(count) {
if (count === 0) {
return '0'
}
if (count > 0) {
return `$\\color{${COLOR_ADDED}}{\\Huge{\\mathbf{+}}}$\u200b${count}`
}
return `$\\color{${COLOR_DELETED}}{\\Huge{\\mathbf{−}}}$\u200b${Math.abs(count)}`
}
function locTableRow(label, bucket) {
const added = bucket.added ?? 0
const deleted = bucket.deleted ?? 0
return `| ${label} | ${bucket.files ?? 0} | ${diffCell(added)} | ${diffCell(-deleted)} | ${diffCell(added - deleted)} |`
}
export function formatLocTable({ test, nonTest }) {
return [
'| | Files | Added | Deleted | Net |',
'| :--- | ---: | ---: | ---: | ---: |',
locTableRow('Test', test),
locTableRow('Prod', nonTest)
].join('\n')
}
export function renderLocBlock(totals) {
return [
LOC_BLOCK_START,
LOC_HANDS_OFF_COMMENT,
'',
formatLocTable(totals),
'',
LOC_BLOCK_END
].join('\n')
}
export function mergeLocBlock(body, totals) {
const block = renderLocBlock(totals)
const current = body ?? ''
const start = current.indexOf(LOC_BLOCK_START)
const end = current.indexOf(LOC_BLOCK_END)
if (start !== -1 && end !== -1 && end > start) {
const rest = current.slice(end + LOC_BLOCK_END.length).replace(/^\r?\n/, '')
if (rest.trim().length === 0) {
return `${current.slice(0, start)}${block}\n`
}
return `${current.slice(0, start)}${block}\n\n${rest.replace(/^\r?\n+/, '')}`
}
if (current.trim().length === 0) {
return `${block}\n`
}
return `${block}\n\n${current.replace(/^\r?\n+/, '')}`
}
+41 -5
View File
@@ -5,13 +5,15 @@ name: Daily macOS Dev Build
# hourly.
#
# Schedule is a single UTC cron (GH Actions has no timezone-aware schedules).
# 14:15 UTC is early morning Pacific year-round (6:15am PST / 7:15am PDT). Minute
# 18:15 UTC is late morning Pacific year-round (10:15am PST / 11:15am PDT). Minute
# 15 avoids stacking with hourly-mac-build, which fires at minute 0 every hour.
#
# Deliberately narrow scope:
# - macOS only. Other platforms keep using RC/stable.
# - No tests, no lint, no e2e. This channel trades safety for latency; PR CI
# and release-cut remain the gates that matter.
# - No tests or lint on the build job. After a live publish we fire-and-forget
# the full E2E workflow at the cut SHA (same detached dispatch as
# release-cut). A red suite must not fail or delay the daily.
# - PR CI and release-cut remain the gates that matter.
# - Signed AND notarized, exactly like a release. The notary round trip is the
# one slow step kept: macOS anchors a notarized app's TCC grants on identifier
# + team rather than on its cdhash, so those grants survive an update. Without
@@ -45,8 +47,8 @@ name: Daily macOS Dev Build
on:
schedule:
# Once a day, early morning Pacific. Single cron — no DST twin, no clock gate.
- cron: '15 14 * * *'
# Once a day, late morning Pacific. Single cron — no DST twin, no clock gate.
- cron: '15 18 * * *'
workflow_dispatch:
inputs:
force:
@@ -70,6 +72,9 @@ env:
jobs:
build-daily-mac:
if: github.repository == 'stablyai/orca'
outputs:
head_sha: ${{ steps.freshness.outputs.head_sha }}
published: ${{ steps.publish_live.outcome == 'success' && 'true' || 'false' }}
runs-on: blacksmith-6vcpu-macos-15
# Why 150: it must exceed the worst case the retry budgets below can produce
# (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or
@@ -469,3 +474,34 @@ jobs:
gh release delete "$tag" --repo "$DAILY_REPO" --yes --cleanup-tag || \
echo "::warning::Could not prune $tag"
done <<<"$stale"
# Why detached dispatch, not a reusable workflow in this graph: the full suite
# is currently red on main. Inlining it here would fail the daily after the
# signed build already published. Same pattern as release-cut's post-release-e2e.
#
# Why --ref main plus the SHA input: daily tags live on orca-daily, not this
# repo, so there is no cut tag for `gh workflow run --ref`. The workflow file
# comes from main; checkout uses the exact commit this daily built.
post-daily-e2e:
needs: build-daily-mac
if: ${{ needs.build-daily-mac.outputs.published == 'true' && needs.build-daily-mac.outputs.head_sha != '' }}
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- name: Dispatch cut-scoped E2E
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SHA: ${{ needs.build-daily-mac.outputs.head_sha }}
run: |
for attempt in 1 2 3; do
if gh workflow run e2e.yml \
--repo "$GITHUB_REPOSITORY" \
--ref main \
--raw-field "ref=$SHA"; then
echo "Dispatched post-daily E2E for $SHA."
exit 0
fi
[[ "$attempt" -eq 3 ]] || sleep "$((attempt * 5))"
done
echo "::warning::Failed to dispatch post-daily E2E for $SHA after 3 attempts."
+36
View File
@@ -0,0 +1,36 @@
name: PR test LoC
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
concurrency:
group: pr-test-loc-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
loc:
name: test vs non-test LoC
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
# Why no checkout: the Files API already has per-file additions/deletions.
- name: Count test vs non-test LoC
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
for script in pr-test-loc-table.mjs pr-test-loc-summary.mjs; do
gh api "repos/${GITHUB_REPOSITORY}/contents/.github/scripts/${script}?ref=pull/${{ github.event.pull_request.number }}/head" \
--jq .content | base64 --decode > "$RUNNER_TEMP/${script}"
done
node "$RUNNER_TEMP/pr-test-loc-summary.mjs" --update-pr "${{ github.event.pull_request.number }}"
+8 -2
View File
@@ -264,7 +264,10 @@ jobs:
src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
src/main/daemon/shell-ready.test.ts \
src/main/daemon/node-pty-fd-leak.test.ts \
src/main/providers/local-pty-shell-ready.test.ts \
src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts \
src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts \
src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \
src/main/providers/__tests__/shell-ready-framework-example.test.ts \
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
@@ -300,7 +303,10 @@ jobs:
--exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
--exclude=src/main/daemon/shell-ready.test.ts \
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
--exclude=src/main/providers/local-pty-shell-ready.test.ts \
--exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
--exclude=src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts \
--exclude=src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts \
--exclude=src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
+96
View File
@@ -923,6 +923,87 @@ jobs:
retention-days: 7
if-no-files-found: ignore
skill-sharing-release-gate:
needs: cut
if: needs.cut.outputs.should_release == 'true'
name: skill sharing release gate ${{ matrix.platform }}
runs-on: ${{ matrix.os }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- os: macos-15
platform: mac
- os: windows-2022
platform: windows
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: refs/tags/${{ needs.cut.outputs.tag }}
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
- name: Install Electron package binary for tests
run: node config/scripts/install-electron-package-binary.mjs
- name: Run skill package, transaction, and compatibility suites
env:
ORCA_REAL_PROCESS_SKILL_TEST: '1'
ORCA_REAL_WINDOWS_SKILL_TEST: ${{ runner.os == 'Windows' && '1' || '0' }}
run: pnpm test:skill-sharing:release --reporter=json --outputFile=skill-sharing-release-results.json
- name: Archive bounded skill-sharing results
if: always()
uses: actions/upload-artifact@v7
with:
name: skill-sharing-release-${{ matrix.platform }}
path: skill-sharing-release-results.json
retention-days: 14
if-no-files-found: error
skill-sharing-linux-floor-release-gate:
needs: cut
if: needs.cut.outputs.should_release == 'true'
name: skill sharing release gate linux-glibc-2.31
runs-on: ubuntu-latest
timeout-minutes: 20
container: ubuntu:20.04
steps:
- name: Install Ubuntu 20.04 prerequisites
run: apt-get update && apt-get install -y build-essential ca-certificates git python3
- name: Checkout
uses: actions/checkout@v6
with:
ref: refs/tags/${{ needs.cut.outputs.tag }}
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
- name: Install Electron package binary for tests
run: node config/scripts/install-electron-package-binary.mjs
- name: Run skill package, transaction, and compatibility suites
env:
ORCA_REAL_PROCESS_SKILL_TEST: '1'
run: pnpm test:skill-sharing:release --reporter=json --outputFile=skill-sharing-release-results.json
- name: Archive bounded skill-sharing results
if: always()
uses: actions/upload-artifact@v7
with:
name: skill-sharing-release-linux-glibc-2.31
path: skill-sharing-release-results.json
retention-days: 14
if-no-files-found: error
# Why: these broader terminal rendering repros are useful release evidence,
# but they include heavier app-like flows and must not block publishing.
terminal-rendering-release-evidence:
@@ -1026,12 +1107,14 @@ jobs:
- os: ubuntu-latest
platform: linux-x64
release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --x64 --publish always
unpacked_dir: dist/linux-unpacked
eb_cache_path: |
~/.cache/electron
~/.cache/electron-builder
- os: ubuntu-24.04-arm
platform: linux-arm64
release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_LINUX_ARM64_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --arm64 --publish always
unpacked_dir: dist/linux-arm64-unpacked
eb_cache_path: |
~/.cache/electron
~/.cache/electron-builder
@@ -1209,6 +1292,17 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Load packaged node-pty on the Linux floor
if: matrix.platform == 'linux-x64' || matrix.platform == 'linux-arm64'
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: >-
node config/scripts/run-linux-packaged-node-pty-floor-smoke.mjs
--app-dir ${{ matrix.unpacked_dir }}
# Why: SignPath signs GitHub workflow artifacts, so Windows builds must
# upload only after the production-signed installer has been returned.
- name: Build Windows release artifacts
@@ -1896,6 +1990,8 @@ jobs:
- cut
- build
- build-mac
- skill-sharing-linux-floor-release-gate
- skill-sharing-release-gate
- terminal-rendering-golden
runs-on: ubuntu-latest
permissions:
+2
View File
@@ -94,6 +94,8 @@ docs/**
!docs/readme/
!docs/readme/**
!docs/STYLEGUIDE.md
!docs/agent-skill-sharing-implementation-checklist.md
!docs/agent-skill-sharing-installation-plan.md
!docs/ai-vault-process-isolation-plan.md
!docs/mobile-terminal-shortcut-bar.md
!docs/reference/
+17 -10
View File
@@ -22,8 +22,7 @@ const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.
const isMacHourly = process.env.ORCA_MAC_HOURLY === '1'
const isMacDaily = process.env.ORCA_MAC_DAILY === '1'
const isMacAdhoc = process.env.ORCA_MAC_ADHOC === '1'
const isMacRelease =
process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacDaily || isMacAdhoc
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacDaily || isMacAdhoc
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION
const devChannelBuildVersion = isMacHourly
@@ -92,6 +91,7 @@ const winSpeechNativeResource = {
module.exports = {
appId,
productName: 'Orca',
protocols: [{ name: 'Orca', schemes: ['orca'] }],
...(devChannelBuildVersion
? { extraMetadata: { version: devChannelBuildVersion } }
: localBuildVersion
@@ -284,8 +284,14 @@ module.exports = {
}
if (context.electronPlatformName === 'darwin') {
await signMacComputerUseHelper(join(resourcesDir, 'Orca Computer Use.app'), context.packager)
await signMacNotificationStatusHelper(
await signMacStandaloneHelper(
join(resourcesDir, '..', 'MacOS', 'orca-notification-status'),
'orca-notification-status',
context.packager
)
await signMacStandaloneHelper(
join(resourcesDir, '..', 'MacOS', 'orca-keyboard-layout'),
'orca-keyboard-layout',
context.packager
)
}
@@ -399,6 +405,10 @@ module.exports = {
{
from: 'native/notification-status-macos/.build/release/orca-notification-status',
to: 'MacOS/orca-notification-status'
},
{
from: 'native/keyboard-layout-macos/.build/release/orca-keyboard-layout',
to: 'MacOS/orca-keyboard-layout'
}
],
target: [
@@ -571,10 +581,10 @@ async function signMacComputerUseHelper(helperAppPath, packager) {
})
}
async function signMacNotificationStatusHelper(helperPath, packager) {
async function signMacStandaloneHelper(helperPath, helperName, packager) {
if (!existsSync(helperPath)) {
if (isMacRelease) {
throw new Error(`Missing orca-notification-status helper at ${helperPath}`)
throw new Error(`Missing ${helperName} helper at ${helperPath}`)
}
return
}
@@ -587,12 +597,9 @@ async function signMacNotificationStatusHelper(helperPath, packager) {
findInstalledMacSigningIdentity(codeSigningInfo?.keychainFile) ??
(isMacRelease ? null : '-')
if (!identity) {
throw new Error('Missing signing identity for orca-notification-status helper')
throw new Error(`Missing signing identity for ${helperName} helper`)
}
// Why: macOS keys notification records to the code-signing identifier; the
// binary embeds the app's CFBundleIdentifier in __TEXT,__info_plist so this
// (and any later) `codesign --force` derives the correct identifier. Sign
// before the outer Orca.app is sealed, like the computer-use helper.
// Why: nested executables must be signed before the outer app bundle is sealed.
const args = ['--force', '--sign', identity]
if (isMacRelease) {
args.push('--options', 'runtime', '--timestamp')
+1 -149
View File
@@ -5,180 +5,89 @@
inline src/cli/handlers/automations.ts
inline src/cli/handlers/orchestration.ts
inline src/cli/help.ts
inline src/cli/index.test.ts
inline src/main/agent-hooks/server.test.ts
inline src/main/agent-hooks/server.ts
inline src/main/amp/hook-service.ts
inline src/main/antigravity/hook-service.ts
inline src/main/automations/external-manager.ts
inline src/main/automations/hermes-cron-output.ts
inline src/main/browser/agent-browser-bridge.test.ts
inline src/main/browser/agent-browser-bridge.ts
inline src/main/browser/browser-cookie-import.ts
inline src/main/browser/browser-guest-ui.ts
inline src/main/browser/browser-manager-grab.test.ts
inline src/main/browser/browser-manager.test.ts
inline src/main/browser/browser-manager.ts
inline src/main/browser/browser-screencast-stream.ts
inline src/main/browser/browser-session-registry.ts
inline src/main/browser/cdp-bridge.ts
inline src/main/browser/cdp-ws-proxy.ts
inline src/main/browser/grab-guest-script.ts
inline src/main/browser/snapshot-engine.ts
inline src/main/claude-accounts/runtime-auth-service.test.ts
inline src/main/claude-accounts/runtime-auth-service.ts
inline src/main/claude-accounts/service.test.ts
inline src/main/claude-accounts/service.ts
inline src/main/claude-usage/scanner.ts
inline src/main/claude-usage/store.ts
inline src/main/cli/cli-installer.test.ts
inline src/main/cli/cli-installer.ts
inline src/main/cli/wsl-cli-installer.ts
inline src/main/codex-accounts/runtime-home-service.test.ts
inline src/main/codex-accounts/runtime-home-service.ts
inline src/main/codex-accounts/service.test.ts
inline src/main/codex-accounts/service.ts
inline src/main/codex-usage/scanner.ts
inline src/main/codex-usage/store.test.ts
inline src/main/codex-usage/store.ts
inline src/main/codex/config-toml-trust.test.ts
inline src/main/codex/config-toml-trust.ts
inline src/main/codex/hook-service.test.ts
inline src/main/codex/hook-service.ts
inline src/main/copilot/hook-service.ts
inline src/main/daemon/client.ts
inline src/main/daemon/daemon-health.ts
inline src/main/daemon/daemon-init.test.ts
inline src/main/daemon/daemon-init.ts
inline src/main/daemon/daemon-pty-adapter.test.ts
inline src/main/daemon/daemon-pty-adapter.ts
inline src/main/daemon/daemon-server.ts
inline src/main/daemon/pty-subprocess.test.ts
inline src/main/daemon/pty-subprocess.ts
inline src/main/daemon/session.ts
inline src/main/daemon/shell-ready.ts
inline src/main/git/remove-worktree.test.ts
inline src/main/git/repo.ts
inline src/main/git/runner.ts
inline src/main/git/status.test.ts
inline src/main/git/status.ts
inline src/main/git/worktree.test.ts
inline src/main/git/worktree.ts
inline src/main/github/client.test.ts
inline src/main/github/client.ts
inline src/main/github/issues.ts
inline src/main/github/pr-refresh-coordinator.test.ts
inline src/main/github/pr-refresh-coordinator.ts
inline src/main/github/project-view.ts
inline src/main/github/project-view/mutations.ts
inline src/main/github/work-item-details.ts
inline src/main/gitlab/client-mr.test.ts
inline src/main/gitlab/client.ts
inline src/main/gitlab/issues.ts
inline src/main/gitlab/work-item-details.ts
inline src/main/hermes/hook-service.ts
inline src/main/hooks.test.ts
inline src/main/hooks.ts
inline src/main/index.ts
inline src/main/ipc/browser.ts
inline src/main/ipc/crash-reporting.ts
inline src/main/ipc/filesystem-auth.ts
inline src/main/ipc/filesystem-mutations.ts
inline src/main/ipc/filesystem-watcher.ts
inline src/main/ipc/filesystem.test.ts
inline src/main/ipc/filesystem.ts
inline src/main/ipc/github.test.ts
inline src/main/ipc/github.ts
inline src/main/ipc/gitlab.ts
inline src/main/ipc/linear.ts
inline src/main/ipc/notifications.test.ts
inline src/main/ipc/notifications.ts
inline src/main/ipc/pet.ts
inline src/main/ipc/preflight.test.ts
inline src/main/ipc/pty.test.ts
inline src/main/ipc/pty.ts
inline src/main/ipc/remote-workspace.ts
inline src/main/ipc/repos-remote.test.ts
inline src/main/ipc/repos.ts
inline src/main/ipc/runtime-environments.test.ts
inline src/main/ipc/ssh.test.ts
inline src/main/ipc/ssh.ts
inline src/main/ipc/worktree-remote.ts
inline src/main/ipc/worktrees.test.ts
inline src/main/ipc/worktrees.ts
inline src/main/jira/client.ts
inline src/main/jira/issues.ts
inline src/main/keybindings/keybinding-file.ts
inline src/main/linear/client.ts
inline src/main/linear/issues.ts
inline src/main/linear/projects.ts
inline src/main/memory/collector.ts
inline src/main/opencode-usage/scanner.ts
inline src/main/opencode-usage/store.ts
inline src/main/opencode/hook-service.ts
inline src/main/persistence.test.ts
inline src/main/persistence.ts
inline src/main/persistence/loading-store/store.ts
inline src/main/ports/advertised-url-watcher.ts
inline src/main/ports/local-workspace-port-scanner.ts
inline src/main/project-groups/nested-repo-discovery.ts
inline src/main/providers/local-pty-provider.test.ts
inline src/main/providers/local-pty-provider.ts
inline src/main/providers/local-pty-shell-ready.test.ts
inline src/main/providers/local-pty-shell-ready.ts
inline src/main/providers/ssh-git-provider.test.ts
inline src/main/providers/ssh-git-provider.ts
inline src/main/rate-limits/claude-fetcher.test.ts
inline src/main/rate-limits/claude-fetcher.ts
inline src/main/rate-limits/claude-pty.ts
inline src/main/rate-limits/codex-fetcher.ts
inline src/main/rate-limits/service.test.ts
inline src/main/rate-limits/service.ts
inline src/main/runtime/orca-runtime-browser.ts
inline src/main/runtime/orca-runtime-files.test.ts
inline src/main/runtime/orca-runtime-files.ts
inline src/main/runtime/orca-runtime-git.ts
inline src/main/runtime/orca-runtime.test.ts
inline src/main/runtime/orca-runtime.ts
inline src/main/runtime/orchestration/coordinator.ts
inline src/main/runtime/orchestration/db.ts
inline src/main/runtime/rpc/methods/files.ts
inline src/main/runtime/rpc/methods/git.ts
inline src/main/runtime/rpc/methods/github.ts
inline src/main/runtime/rpc/methods/orchestration.test.ts
inline src/main/runtime/rpc/methods/orchestration.ts
inline src/main/runtime/rpc/methods/terminal.ts
inline src/main/runtime/rpc/terminal-multiplex.test.ts
inline src/main/runtime/runtime-rpc.test.ts
inline src/main/runtime/runtime-rpc.ts
inline src/main/source-control/hosted-review-creation.ts
inline src/main/speech/model-manager.ts
inline src/main/speech/stt-service.ts
inline src/main/ssh/ssh-channel-multiplexer.ts
inline src/main/ssh/ssh-connection.test.ts
inline src/main/ssh/ssh-connection.ts
inline src/main/ssh/ssh-relay-deploy.ts
inline src/main/ssh/ssh-relay-session.ts
inline src/main/star-nag/service.test.ts
inline src/main/text-generation/commit-message-text-generation.test.ts
inline src/main/text-generation/commit-message-text-generation.ts
inline src/main/updater.test.ts
inline src/main/updater.ts
inline src/main/window/attach-main-window-services.ts
inline src/main/window/createMainWindow.test.ts
inline src/main/window/createMainWindow.ts
inline src/main/workspace-space-analysis.ts
inline src/preload/index.ts
inline src/relay/agent-hook-server.ts
inline src/relay/dispatcher.ts
inline src/relay/external-automations-handler.ts
inline src/relay/fs-handler.ts
inline src/relay/git-handler.test.ts
inline src/relay/git-handler.ts
inline src/relay/pty-handler.test.ts
inline src/relay/pty-handler.ts
inline src/relay/relay.ts
inline src/relay/workspace-space-scan.ts
inline src/renderer/src/components/GitHubItemDialog.tsx
inline src/renderer/src/components/GitLabItemDialog.tsx
inline src/renderer/src/components/JiraIssueWorkspace.tsx
inline src/renderer/src/components/LinearIssueWorkspace.tsx
@@ -189,23 +98,16 @@ inline src/renderer/src/components/TaskPage.tsx
inline src/renderer/src/components/Terminal.tsx
inline src/renderer/src/components/UpdateCard.tsx
inline src/renderer/src/components/WorktreeJumpPalette.tsx
inline src/renderer/src/components/activity/ActivityPrototypePage.test.ts
inline src/renderer/src/components/activity/ActivityPrototypePage.tsx
inline src/renderer/src/components/automations/AutomationsPage.tsx
inline src/renderer/src/components/browser-pane/BrowserPane.tsx
inline src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx
inline src/renderer/src/components/editor/CombinedDiffViewer.tsx
inline src/renderer/src/components/editor/EditorContent.tsx
inline src/renderer/src/components/editor/IpynbViewer.tsx
inline src/renderer/src/components/editor/MarkdownPreview.tsx
inline src/renderer/src/components/editor/MonacoEditor.tsx
inline src/renderer/src/components/editor/editor-autosave-controller.ts
inline src/renderer/src/components/editor/ipynb-parse.ts
inline src/renderer/src/components/editor/useEditorPanelContentState.ts
inline src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx
inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx
inline src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
inline src/renderer/src/components/github-project/ProjectCell.tsx
inline src/renderer/src/components/github-project/ProjectPicker.tsx
@@ -214,107 +116,58 @@ inline src/renderer/src/components/linear-project-view-surfaces.tsx
inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
inline src/renderer/src/components/onboarding/use-onboarding-flow.ts
inline src/renderer/src/components/right-sidebar/ChecksPanel.tsx
inline src/renderer/src/components/right-sidebar/FileExplorer.test.tsx
inline src/renderer/src/components/right-sidebar/FileExplorer.tsx
inline src/renderer/src/components/right-sidebar/FileExplorerRow.tsx
inline src/renderer/src/components/right-sidebar/PortsPanel.tsx
inline src/renderer/src/components/right-sidebar/SourceControl.tsx
inline src/renderer/src/components/right-sidebar/checks-panel-content.tsx
inline src/renderer/src/components/right-sidebar/index.tsx
inline src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts
inline src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts
inline src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts
inline src/renderer/src/components/settings/AccountsPane.tsx
inline src/renderer/src/components/settings/AgentsPane.tsx
inline src/renderer/src/components/settings/RepositoryHooksSection.tsx
inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
inline src/renderer/src/components/settings/Settings.tsx
inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx
inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
inline src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts
inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts
inline src/renderer/src/components/sidebar/worktree-list-groups.test.ts
inline src/renderer/src/components/sidebar/worktree-list-groups.ts
inline src/renderer/src/components/stats/usage-overview-model.ts
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
inline src/renderer/src/components/status-bar/StatusBar.tsx
inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
inline src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts
inline src/renderer/src/components/tab-group/useTabDragSplit.ts
inline src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts
inline src/renderer/src/components/terminal-pane/TerminalPane.tsx
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts
inline src/renderer/src/components/terminal-pane/keyboard-handlers.ts
inline src/renderer/src/components/terminal-pane/pty-connection.test.ts
inline src/renderer/src/components/terminal-pane/pty-connection.ts
inline src/renderer/src/components/terminal-pane/pty-transport.test.ts
inline src/renderer/src/components/terminal-pane/pty-transport.ts
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
inline src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
inline src/renderer/src/hooks/useAutomationDispatchEvents.ts
inline src/renderer/src/hooks/useComposerState.ts
inline src/renderer/src/hooks/useEditorExternalWatch.ts
inline src/renderer/src/hooks/useIpcEvents.test.ts
inline src/renderer/src/hooks/useIpcEvents.ts
inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts
inline src/renderer/src/lib/active-agent-note-send.test.ts
inline src/renderer/src/lib/file-type-icons.ts
inline src/renderer/src/lib/pane-manager/pane-manager.ts
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts
inline src/renderer/src/lib/worktree-activation.test.ts
inline src/renderer/src/lib/worktree-activation.ts
inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
inline src/renderer/src/runtime/runtime-file-client.test.ts
inline src/renderer/src/runtime/runtime-file-client.ts
inline src/renderer/src/runtime/runtime-git-client.ts
inline src/renderer/src/runtime/runtime-linear-client.ts
inline src/renderer/src/runtime/sync-runtime-graph.test.ts
inline src/renderer/src/runtime/sync-runtime-graph.ts
inline src/renderer/src/runtime/web-runtime-session.test.ts
inline src/renderer/src/runtime/web-runtime-session.ts
inline src/renderer/src/runtime/web-session-tabs-sync.test.ts
inline src/renderer/src/runtime/web-session-tabs-sync.ts
inline src/renderer/src/store/slices/agent-status.test.ts
inline src/renderer/src/store/slices/agent-status.ts
inline src/renderer/src/store/slices/browser.test.ts
inline src/renderer/src/store/slices/browser.ts
inline src/renderer/src/store/slices/diffComments.ts
inline src/renderer/src/store/slices/editor.test.ts
inline src/renderer/src/store/slices/editor.ts
inline src/renderer/src/store/slices/github.test.ts
inline src/renderer/src/store/slices/github.ts
inline src/renderer/src/store/slices/hosted-review.ts
inline src/renderer/src/store/slices/jira.ts
inline src/renderer/src/store/slices/linear.test.ts
inline src/renderer/src/store/slices/linear.ts
inline src/renderer/src/store/slices/repos.ts
inline src/renderer/src/store/slices/store-cascades.test.ts
inline src/renderer/src/store/slices/store-session-cascades.test.ts
inline src/renderer/src/store/slices/tabs.test.ts
inline src/renderer/src/store/slices/tabs.ts
inline src/renderer/src/store/slices/terminals.ts
inline src/renderer/src/store/slices/ui.test.ts
inline src/renderer/src/store/slices/ui.ts
inline src/renderer/src/store/slices/workspace-cleanup.ts
inline src/renderer/src/store/slices/worktrees.test.ts
inline src/renderer/src/web/web-preload-api.test.ts
inline src/renderer/src/web/web-preload-api.ts
inline src/renderer/src/web/web-runtime-client.ts
inline src/shared/agent-hook-listener.test.ts
inline src/shared/agent-hook-listener.ts
inline src/shared/automation-schedules.ts
inline src/shared/commit-message-agent-spec.ts
inline src/shared/constants.ts
inline src/shared/github/project-types.ts
inline src/shared/keybindings.test.ts
inline src/shared/keybindings.ts
inline src/shared/marine-creatures.ts
inline src/shared/remote-runtime-client.ts
@@ -323,7 +176,6 @@ inline src/shared/source-control-ai.ts
inline src/shared/telemetry-events.ts
inline src/shared/text-search.ts
inline tests/e2e/helpers/terminal.ts
inline tests/e2e/terminal-panes.spec.ts
mobile-config app/h/*/files/*.tsx
mobile-config app/h/*/index.tsx
mobile-config app/h/*/session/*.tsx
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process'
import { chmodSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
const repoRoot = path.resolve(import.meta.dirname, '../..')
const sourcePath = path.join(repoRoot, 'native', 'keyboard-layout-macos', 'main.swift')
const defaultOutputPath = path.join(
repoRoot,
'native',
'keyboard-layout-macos',
'.build',
'release',
'orca-keyboard-layout'
)
if (process.platform !== 'darwin') {
process.exit(0)
}
const args = process.argv.slice(2)
const outputPath = readArg('--output') ?? defaultOutputPath
const singleArch = args.includes('--single-arch')
const workDir = mkdtempSync(path.join(tmpdir(), 'orca-keyboard-layout-'))
try {
const triples = singleArch
? [process.arch === 'arm64' ? 'arm64-apple-macosx' : 'x86_64-apple-macosx']
: ['arm64-apple-macosx', 'x86_64-apple-macosx']
const builtBinaries = triples.map((triple) => {
const output = path.join(workDir, `orca-keyboard-layout-${triple}`)
execFileSync(
'swiftc',
[
'-O',
sourcePath,
'-target',
triple.replace('-apple-macosx', '-apple-macosx11.0'),
'-o',
output
],
{ stdio: 'inherit' }
)
return output
})
mkdirSync(path.dirname(outputPath), { recursive: true })
if (builtBinaries.length === 1) {
execFileSync('cp', [builtBinaries[0], outputPath])
} else {
execFileSync('lipo', ['-create', ...builtBinaries, '-output', outputPath])
}
chmodSync(outputPath, 0o755)
} finally {
rmSync(workDir, { recursive: true, force: true })
}
function readArg(name) {
const index = args.indexOf(name)
return index === -1 ? undefined : args[index + 1]
}
@@ -13,6 +13,7 @@ if (process.platform !== 'darwin') {
}
runPnpmScript('build:computer-macos')
runPnpmScript('build:keyboard-layout-macos')
runPnpmScript('build:notification-status-macos')
process.exit(0)
@@ -0,0 +1,65 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const projectDir = resolve(import.meta.dirname, '../..')
function source(path) {
return readFileSync(join(projectDir, path), 'utf8')
}
function sourceBetween(contents, startMarker, endMarker) {
const start = contents.indexOf(startMarker)
const end = contents.indexOf(endMarker, start + startMarker.length)
if (start === -1 || end === -1) {
throw new Error(`Missing source boundary: ${startMarker} → ${endMarker}`)
}
return contents.slice(start, end)
}
describe('computer-use mouse button routing', () => {
it('maps the macOS middle button onto the otherMouse event family', () => {
const macOS = source('native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift')
const mapping = sourceBetween(
macOS,
'extension MouseButtonSelection {',
'private func mouseButton('
)
expect(mapping).toContain('return .center')
expect(mapping).toContain('return .otherMouseDown')
expect(mapping).toContain('return .otherMouseUp')
// A middle press posted as a left event type would silently left-click.
expect(mapping).not.toContain('case .middle:\n return .leftMouseDown')
})
it('validates the macOS mouse button before any accessibility shortcut runs', () => {
const macOS = source('native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift')
const click = sourceBetween(
macOS,
'private func click(params:',
'private func performClickAction('
)
expect(click).toContain('let button = try mouseButton(params["mouseButton"]?.string)')
expect(click).toContain('button.hasAccessibilityAction')
// An unvalidated raw string reaches AXPress and reports a left click as success.
expect(click).not.toContain('params["mouseButton"]?.string ?? "left"')
})
it('keeps every platform from resolving a middle click through its accessibility path', () => {
const windows = source('native/computer-use-windows/runtime.ps1')
const windowsClick = sourceBetween(
windows,
'$handledByPattern = $false',
'if (-not $handledByPattern)'
)
expect(windowsClick).toContain('$Operation.mouse_button -ne "middle"')
const linux = source('native/computer-use-linux/runtime.py')
const linuxClick = sourceBetween(linux, 'has_modifiers = bool(', 'if not handled:')
expect(linuxClick).toContain('operation.get("mouse_button", "left") == "left"')
})
})
+1 -1
View File
@@ -60,7 +60,7 @@ export function nextDailyBuildNumber(baseVersion, releaseNames = []) {
}
/**
* `1.4.163 • 01 • Aug 9, 6:15AM • e698241` — the human-facing release title,
* `1.4.163 • 01 • Aug 9, 10:15AM • e698241` — the human-facing release title,
* shown verbatim in both the GitHub releases list and the in-app build picker.
*/
export function formatDailyReleaseName(version, buildNumber, commit, date) {
+9 -9
View File
@@ -45,27 +45,27 @@ describe('formatDailyReleaseName', () => {
formatDailyReleaseName('1.4.163-daily.x', buildNumber, commit, new Date(iso))
it('renders version, number, Pacific timestamp, and short sha', () => {
// 13:15 UTC is 6:15AM PDT in July (the daily cut time).
expect(name('2026-07-28T13:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 6:15AM • e698241')
// 17:15 UTC is 10:15AM PDT in July (the daily cut time).
expect(name('2026-07-28T17:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 10:15AM • e698241')
})
// Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so
// the offset between them is not a constant. A test pinned to one season would
// pass all summer and start failing in November.
it('follows the Pacific offset across DST', () => {
expect(name('2026-01-15T14:15:00Z')).toBe('1.4.163 • 01 • Jan 15, 6:15AM • e698241')
expect(name('2026-07-28T13:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 6:15AM • e698241')
expect(name('2026-01-15T18:15:00Z')).toBe('1.4.163 • 01 • Jan 15, 10:15AM • e698241')
expect(name('2026-07-28T17:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 10:15AM • e698241')
})
it('pads to two digits and grows past them', () => {
expect(name('2026-07-28T13:15:00Z', 9)).toContain(' • 09 • ')
expect(name('2026-07-28T13:15:00Z', 42)).toContain(' • 42 • ')
expect(name('2026-07-28T17:15:00Z', 9)).toContain(' • 09 • ')
expect(name('2026-07-28T17:15:00Z', 42)).toContain(' • 42 • ')
})
it('rejects a build number that is not a positive integer', () => {
expect(() => name('2026-07-28T13:15:00Z', 0)).toThrow(/positive integer/)
expect(() => name('2026-07-28T13:15:00Z', -1)).toThrow(/positive integer/)
expect(() => name('2026-07-28T13:15:00Z', 1.5)).toThrow(/positive integer/)
expect(() => name('2026-07-28T17:15:00Z', 0)).toThrow(/positive integer/)
expect(() => name('2026-07-28T17:15:00Z', -1)).toThrow(/positive integer/)
expect(() => name('2026-07-28T17:15:00Z', 1.5)).toThrow(/positive integer/)
})
it('rejects an invalid timestamp', () => {
@@ -0,0 +1,45 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
const projectDir = resolve(import.meta.dirname, '../..')
const dailyWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/daily-mac-build.yml'), 'utf8')
)
const e2eWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
describe('daily E2E dispatch contract', () => {
it('dispatches SHA-scoped full E2E only after a live daily publish', () => {
const buildJob = dailyWorkflow.jobs['build-daily-mac']
const dispatchJob = dailyWorkflow.jobs['post-daily-e2e']
const dispatchStep = dispatchJob.steps.find((step) => step.name === 'Dispatch cut-scoped E2E')
expect(dailyWorkflow.jobs.e2e).toBeUndefined()
expect(buildJob.outputs.head_sha).toBe('${{ steps.freshness.outputs.head_sha }}')
expect(buildJob.outputs.published).toBe(
"${{ steps.publish_live.outcome == 'success' && 'true' || 'false' }}"
)
expect(dispatchJob.needs).toBe('build-daily-mac')
expect(dispatchJob.if).toBe(
"${{ needs.build-daily-mac.outputs.published == 'true' && needs.build-daily-mac.outputs.head_sha != '' }}"
)
expect(dispatchJob.permissions.actions).toBe('write')
expect(dispatchStep.env.SHA).toBe('${{ needs.build-daily-mac.outputs.head_sha }}')
expect(dispatchStep.run).toContain('gh workflow run e2e.yml')
expect(dispatchStep.run).toContain('--ref main')
expect(dispatchStep.run).toContain('--raw-field "ref=$SHA"')
expect(dispatchStep.run).toContain('for attempt in 1 2 3')
expect(dispatchStep.run).toContain('[[ "$attempt" -eq 3 ]] || sleep')
expect(dispatchStep.run).toContain('::warning::Failed to dispatch post-daily E2E')
})
it('leaves test_files unset so the dispatched run takes the full suite', () => {
const dispatchJob = dailyWorkflow.jobs['post-daily-e2e']
const dispatchStep = dispatchJob.steps.find((step) => step.name === 'Dispatch cut-scoped E2E')
expect(dispatchStep.run).not.toContain('test_files')
expect(e2eWorkflow.on.workflow_call.inputs.test_files.required).toBe(false)
expect(e2eWorkflow.jobs.e2e.if).toBe("inputs.test_files == ''")
})
})
@@ -155,6 +155,20 @@ describe('electron-builder config', () => {
)
})
it('ships the mac keyboard-layout helper in Contents/MacOS, not Resources', () => {
expect(electronBuilderConfig.mac.extraFiles).toEqual(
expect.arrayContaining([
expect.objectContaining({
from: 'native/keyboard-layout-macos/.build/release/orca-keyboard-layout',
to: 'MacOS/orca-keyboard-layout'
})
])
)
expect(electronBuilderConfig.mac.extraResources).not.toEqual(
expect.arrayContaining([expect.objectContaining({ to: 'orca-keyboard-layout' })])
)
})
it('unpacks the compiled CommonJS boundary with CLI runtime files', () => {
expect(electronBuilderConfig.asarUnpack).toEqual(
expect.arrayContaining([
@@ -0,0 +1,47 @@
const { join } = require('node:path')
const resourcesDirectory = process.argv[2]
if (!resourcesDirectory) {
throw new Error('linux-packaged-node-pty-floor-resources-required')
}
const pty = require(join(resourcesDirectory, 'node_modules', 'node-pty'))
const expected = 'orca-node-pty-floor-ok'
let output = ''
let settled = false
const child = pty.spawn('/bin/sh', ['-c', `printf ${expected}; sleep 0.1`], {
name: 'xterm-color',
cols: 80,
rows: 24,
cwd: '/tmp',
env: { PATH: process.env.PATH ?? '/usr/bin:/bin' }
})
const timeout = setTimeout(() => {
if (settled) {
return
}
settled = true
child.kill()
process.stderr.write('packaged node-pty floor smoke timed out\n')
process.exitCode = 1
}, 5_000)
child.onData((data) => {
output = `${output}${data}`.slice(-256)
})
child.onExit(({ exitCode }) => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
if (exitCode !== 0 || output !== expected) {
process.stderr.write(
`packaged node-pty floor smoke failed: exit=${exitCode} bytes=${Buffer.byteLength(output)}\n`
)
process.exitCode = 1
return
}
process.stdout.write(`${expected}\n`)
})
@@ -90,17 +90,21 @@ describe('Electron runtime package contract', () => {
}
})
it('keeps Windows and Linux package builds off the macOS native helper build', () => {
it('keeps Windows and Linux package builds off macOS native helper builds', () => {
const scripts = packageJson.scripts
expect(scripts['build:desktop']).not.toContain('build:computer-macos')
expect(scripts['build:desktop']).not.toContain('build:keyboard-layout-macos')
expect(scripts['build:win']).toContain('pnpm run build:desktop')
expect(scripts['build:win']).not.toContain('pnpm run build ')
expect(scripts['build:win']).not.toContain('build:computer-macos')
expect(scripts['build:win']).not.toContain('build:keyboard-layout-macos')
expect(scripts['build:linux']).toContain('pnpm run build:desktop')
expect(scripts['build:linux']).not.toContain('pnpm run build ')
expect(scripts['build:linux']).not.toContain('build:computer-macos')
expect(scripts['build:linux']).not.toContain('build:keyboard-layout-macos')
expect(scripts['build:mac']).toContain('pnpm run build:computer-macos')
expect(scripts['build:mac']).toContain('pnpm run build:keyboard-layout-macos')
expect(scripts['build:release']).toContain('pnpm run build:native')
expect(scripts['build:release']).not.toContain('build:computer-macos')
})
+197
View File
@@ -0,0 +1,197 @@
import { spawnSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { listPullFiles, nextLink } from '../../.github/scripts/pr-test-loc-summary.mjs'
import {
LOC_HANDS_OFF_COMMENT,
isTestPath,
mergeLocBlock,
renderLocBlock,
sumChangedFiles
} from '../../.github/scripts/pr-test-loc-table.mjs'
const projectDir = resolve(import.meta.dirname, '../..')
const locScript = join(projectDir, '.github/scripts/pr-test-loc-summary.mjs')
const tempDirs = []
function runLoc(args, { env } = {}) {
return spawnSync(process.execPath, [locScript, ...args], {
cwd: projectDir,
encoding: 'utf8',
env: { ...process.env, ...env }
})
}
afterEach(() => {
while (tempDirs.length > 0) {
rmSync(tempDirs.pop(), { force: true, recursive: true })
}
})
describe('PR test LoC summary', () => {
it('classifies colocated tests and e2e/tests directories', () => {
expect(isTestPath('src/main/foo.test.ts')).toBe(true)
expect(isTestPath('src/main/foo.spec.tsx')).toBe(true)
expect(isTestPath('src/e2e/login.ts')).toBe(true)
expect(isTestPath('tests/tools/probe.mjs')).toBe(true)
expect(isTestPath('src/main/foo-test-setup.ts')).toBe(false)
expect(isTestPath('src/main/foo.ts')).toBe(false)
})
it('sums GitHub pull-file additions and deletions', () => {
const totals = sumChangedFiles([
{ filename: 'src/app.ts', additions: 4, deletions: 1 },
{ filename: 'src/app.test.ts', additions: 12, deletions: 3 },
{ filename: 'icon.png', additions: 0, deletions: 0 }
])
expect(totals).toEqual({
test: { files: 1, added: 12, deleted: 3 },
nonTest: { files: 2, added: 4, deleted: 1 }
})
})
it('reads the next page from a GitHub Link header', () => {
expect(
nextLink(
'<https://api.github.com/repos/stablyai/orca/pulls/1/files?page=2>; rel="next", <https://api.github.com/repos/stablyai/orca/pulls/1/files?page=3>; rel="last"'
)
).toBe('https://api.github.com/repos/stablyai/orca/pulls/1/files?page=2')
expect(
nextLink('<https://api.github.com/repos/stablyai/orca/pulls/1/files?page=1>; rel="prev"')
).toBe(undefined)
})
it('paginates pull files until Link rel=next is gone', async () => {
const pages = {
'https://api.github.com/repos/stablyai/orca/pulls/9/files?per_page=100': {
body: [{ filename: 'src/app.ts', additions: 2, deletions: 0 }],
link: '<https://api.github.com/repos/stablyai/orca/pulls/9/files?page=2>; rel="next"'
},
'https://api.github.com/repos/stablyai/orca/pulls/9/files?page=2': {
body: [{ filename: 'src/app.test.ts', additions: 5, deletions: 1 }],
link: null
}
}
const files = await listPullFiles({
owner: 'stablyai',
repo: 'orca',
pullNumber: 9,
token: 'test-token',
fetchImpl: async (url) => {
const page = pages[url]
if (page == null) {
throw new Error(`unexpected url ${String(url)}`)
}
return {
ok: true,
json: async () => page.body,
headers: { get: (name) => (name === 'link' ? page.link : null) }
}
}
})
expect(sumChangedFiles(files)).toEqual({
test: { files: 1, added: 5, deleted: 1 },
nonTest: { files: 1, added: 2, deleted: 0 }
})
})
it('colors added cells green and deleted or negative-net cells red', () => {
const block = renderLocBlock({
test: { files: 1, added: 2, deleted: 5 },
nonTest: { files: 1, added: 0, deleted: 4 }
})
expect(block).toContain(
'| Test | 1 | $\\color{#1a7f37}{\\Huge{\\mathbf{+}}}$\u200b2 | $\\color{#cf222e}{\\Huge{\\mathbf{−}}}$\u200b5 | $\\color{#cf222e}{\\Huge{\\mathbf{−}}}$\u200b3 |'
)
expect(block).toContain(
'| Prod | 1 | 0 | $\\color{#cf222e}{\\Huge{\\mathbf{−}}}$\u200b4 | $\\color{#cf222e}{\\Huge{\\mathbf{−}}}$\u200b4 |'
)
})
it('replaces an existing header and prepends when missing', () => {
const totals = {
test: { files: 1, added: 2, deleted: 1 },
nonTest: { files: 1, added: 4, deleted: 0 }
}
const block = renderLocBlock(totals)
expect(block).toContain(LOC_HANDS_OFF_COMMENT)
expect(block).toContain(
'| Test | 1 | $\\color{#1a7f37}{\\Huge{\\mathbf{+}}}$\u200b2 | $\\color{#cf222e}{\\Huge{\\mathbf{−}}}$\u200b1 | $\\color{#1a7f37}{\\Huge{\\mathbf{+}}}$\u200b1 |'
)
expect(block).toContain(
'| Prod | 1 | $\\color{#1a7f37}{\\Huge{\\mathbf{+}}}$\u200b4 | 0 | $\\color{#1a7f37}{\\Huge{\\mathbf{+}}}$\u200b4 |'
)
expect(block).not.toContain('| Total |')
expect(mergeLocBlock('## ELI5\n\nHello\n', totals)).toBe(`${block}\n\n## ELI5\n\nHello\n`)
expect(mergeLocBlock(`${block}\n\n## ELI5\n`, totals)).toBe(`${block}\n\n## ELI5\n`)
expect(
mergeLocBlock(
'<!-- orca-pr-loc -->\n**LoC** · test **+1 / −0**\n<!-- /orca-pr-loc -->\n\n## ELI5\n',
totals
)
).toBe(`${block}\n\n## ELI5\n`)
})
it('counts and merges from a files JSON fixture', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-pr-test-loc-'))
tempDirs.push(root)
const filesPath = join(root, 'files.json')
const bodyPath = join(root, 'body.md')
writeFileSync(
filesPath,
JSON.stringify([
{ filename: 'src/app.ts', additions: 2, deletions: 0 },
{ filename: 'src/app.test.ts', additions: 6, deletions: 1 }
])
)
writeFileSync(bodyPath, '## ELI5\n\nHello\n')
const result = runLoc(['--files-json', filesPath, '--merge-body', bodyPath])
expect(result.status).toBe(0)
expect(result.stdout.startsWith('<!-- orca-pr-loc -->')).toBe(true)
expect(result.stdout).toContain(LOC_HANDS_OFF_COMMENT)
expect(result.stdout).toContain(
'| Test | 1 | $\\color{#1a7f37}{\\Huge{\\mathbf{+}}}$\u200b6 | $\\color{#cf222e}{\\Huge{\\mathbf{−}}}$\u200b1 | $\\color{#1a7f37}{\\Huge{\\mathbf{+}}}$\u200b5 |'
)
expect(result.stdout).toContain(
'| Prod | 1 | $\\color{#1a7f37}{\\Huge{\\mathbf{+}}}$\u200b2 | 0 | $\\color{#1a7f37}{\\Huge{\\mathbf{+}}}$\u200b2 |'
)
expect(result.stdout).not.toContain('| Total |')
expect(result.stdout).toContain('## ELI5\n\nHello\n')
expect(result.stdout.match(/<!-- orca-pr-loc -->/g)).toHaveLength(1)
})
it('exits 2 with usage when no PR or files JSON is supplied', () => {
const result = runLoc([])
expect(result.status).toBe(2)
expect(result.stderr).toContain('--from-pr')
})
it('is a no-checkout GitHub-hosted PR workflow', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/pr-test-loc.yml'), 'utf8')
)
const locJob = workflow.jobs.loc
const serialized = JSON.stringify(workflow)
expect(locJob['runs-on']).toBe('ubuntu-latest')
expect(locJob.steps).toHaveLength(1)
expect(locJob.steps[0].run).toContain('gh api')
expect(locJob.steps[0].run).toContain('pr-test-loc-table.mjs')
expect(locJob.steps[0].run).toContain('pr-test-loc-summary.mjs')
expect(locJob.steps[0].run).toContain('--update-pr')
expect(workflow.permissions['pull-requests']).toBe('write')
expect(serialized).not.toContain('actions/checkout')
expect(serialized).not.toContain('self-hosted')
})
})
@@ -10,7 +10,10 @@ const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
const shellContractFiles = [
'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts',
'src/main/daemon/shell-ready.test.ts',
'src/main/providers/local-pty-shell-ready.test.ts',
'src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts',
'src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts',
'src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts',
'src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts',
'src/main/providers/__tests__/shell-ready-framework-example.test.ts',
'src/shared/posix-command-path-lookup.test.ts'
]
+22 -3
View File
@@ -128,8 +128,9 @@ function prepareMacDevElectronApp() {
const title = process.env.ORCA_DEV_DOCK_TITLE || 'Orca: dev'
const identityKey = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot
// v7: give the terminal daemon helper an Orca-specific TCC identity.
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v7'
// v10: add the keyboard-layout helper. A stale copy only emits extra fields
// the parser ignores, so narrowing its schema needs no bump.
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v10'
const hash = createHash('sha1')
.update(
`${sourceAppPath}\0${electronVersion ?? ''}\0${title}\0${identityKey}\0${bundleLayoutVersion}`
@@ -169,7 +170,8 @@ function prepareMacDevElectronApp() {
'Electron Framework.framework',
'Resources',
'icudtl.dat'
)
),
path.join(appPath, 'Contents', 'MacOS', 'orca-keyboard-layout')
]
function copiedAppIsUsable() {
@@ -242,6 +244,23 @@ function prepareMacDevElectronApp() {
)
}
try {
execFileSync(
process.execPath,
[
path.join(repoRoot, 'config', 'scripts', 'build-keyboard-layout-macos.mjs'),
'--single-arch',
'--output',
path.join(appPath, 'Contents', 'MacOS', 'orca-keyboard-layout')
],
{ stdio: 'inherit' }
)
} catch (error) {
console.warn(
`[orca-dev] keyboard-layout helper build failed (shifted Option composition stays conservative): ${error?.message ?? error}`
)
}
// Why: the plist edits above (and the copy itself) break the bundle's
// ad-hoc seal, and macOS refuses Notification Center registration for
// invalidly-signed apps — every dev notification fails with UNErrorDomain
@@ -0,0 +1,94 @@
import { existsSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import { isAbsolute, relative, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
const FLOOR_IMAGE = 'ubuntu:20.04'
const LINUX_EXECUTABLE = 'orca-ide'
const FLOOR_PACKAGES = [
'ca-certificates',
'libasound2',
'libatspi2.0-0',
'libdrm2',
'libgbm1',
'libgtk-3-0',
'libnss3',
'libx11-xcb1',
'libxkbcommon0',
'libxss1'
]
function workspaceRelativePath(workspaceDirectory, inputPath) {
const absolutePath = resolve(workspaceDirectory, inputPath)
const relativePath = relative(workspaceDirectory, absolutePath)
if (!relativePath || relativePath === '..' || relativePath.startsWith(`..${sep}`)) {
throw new Error('linux-packaged-node-pty-floor-app-directory-invalid')
}
return relativePath.split(sep).join('/')
}
export function packagedNodePtyFloorDockerArgs({ workspaceDirectory, appDirectory }) {
const relativeAppDirectory = workspaceRelativePath(workspaceDirectory, appDirectory)
const containerAppDirectory = `/workspace/${relativeAppDirectory}`
const command = [
'export DEBIAN_FRONTEND=noninteractive',
'apt-get update -qq',
`apt-get install -y -qq ${FLOOR_PACKAGES.join(' ')} >/dev/null`,
`ELECTRON_RUN_AS_NODE=1 ${containerAppDirectory}/${LINUX_EXECUTABLE} ` +
'/workspace/config/scripts/linux-packaged-node-pty-floor-child.cjs ' +
`${containerAppDirectory}/resources`
].join(' && ')
return [
'run',
'--rm',
'--mount',
`type=bind,src=${workspaceDirectory},dst=/workspace,readonly`,
'--workdir',
'/workspace',
FLOOR_IMAGE,
'/bin/bash',
'-lc',
command
]
}
function parseAppDirectory(argv) {
const index = argv.indexOf('--app-dir')
const value = index !== -1 ? argv[index + 1] : undefined
if (!value || isAbsolute(value) || value.startsWith('-')) {
throw new Error('Usage: run-linux-packaged-node-pty-floor-smoke.mjs --app-dir <relative-path>')
}
return value
}
export function runPackagedNodePtyFloorSmoke({
workspaceDirectory = process.cwd(),
appDirectory,
spawn = spawnSync
}) {
if (process.platform !== 'linux') {
throw new Error('linux-packaged-node-pty-floor-smoke-requires-linux')
}
const absoluteAppDirectory = resolve(workspaceDirectory, appDirectory)
if (!existsSync(resolve(absoluteAppDirectory, LINUX_EXECUTABLE))) {
throw new Error(`linux-packaged-node-pty-floor-executable-missing: ${absoluteAppDirectory}`)
}
const result = spawn(
'docker',
packagedNodePtyFloorDockerArgs({ workspaceDirectory, appDirectory }),
{ encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }
)
if (result.error || result.signal || result.status !== 0) {
const detail =
result.error?.message ||
result.stderr?.trim() ||
`status-${result.status}-signal-${result.signal ?? 'none'}`
throw new Error(`linux-packaged-node-pty-floor-smoke-failed: ${detail}`)
}
process.stdout.write(result.stdout)
}
const isMain = process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url
if (isMain) {
runPackagedNodePtyFloorSmoke({ appDirectory: parseAppDirectory(process.argv.slice(2)) })
}
@@ -0,0 +1,36 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { packagedNodePtyFloorDockerArgs } from './run-linux-packaged-node-pty-floor-smoke.mjs'
describe('packaged node-pty Linux floor smoke', () => {
it('mounts the exact package read-only in an Ubuntu 20.04 container', () => {
const args = packagedNodePtyFloorDockerArgs({
workspaceDirectory: '/repo',
appDirectory: 'dist/linux-arm64-unpacked'
})
expect(args).toContain('type=bind,src=/repo,dst=/workspace,readonly')
expect(args).toContain('ubuntu:20.04')
expect(args.at(-1)).toContain('/workspace/dist/linux-arm64-unpacked/orca-ide')
expect(args.at(-1)).toContain('ELECTRON_RUN_AS_NODE=1')
})
it('rejects an app directory outside the workspace', () => {
expect(() =>
packagedNodePtyFloorDockerArgs({
workspaceDirectory: '/repo',
appDirectory: '../outside'
})
).toThrow('linux-packaged-node-pty-floor-app-directory-invalid')
})
it('loads node-pty from packaged runtime resources', () => {
const childSource = readFileSync(
new URL('./linux-packaged-node-pty-floor-child.cjs', import.meta.url),
'utf8'
)
expect(childSource).toContain("join(resourcesDirectory, 'node_modules', 'node-pty')")
expect(childSource).not.toContain("join(resourcesDirectory, 'app.asar'")
})
})
@@ -0,0 +1,82 @@
import { readFileSync } from 'node:fs'
import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
const workflow = parse(readFileSync('.github/workflows/release-cut.yml', 'utf8'))
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
function stepNamed(job, name) {
return job.steps.find((step) => step.name === name)
}
describe('skill-sharing release workflow', () => {
it('blocks publication on native Windows, macOS, and the Linux floor', () => {
const platform = workflow.jobs['skill-sharing-release-gate']
const linux = workflow.jobs['skill-sharing-linux-floor-release-gate']
const publishNeeds = workflow.jobs['publish-release'].needs
expect(platform.strategy.matrix.include).toEqual([
{ os: 'macos-15', platform: 'mac' },
{ os: 'windows-2022', platform: 'windows' }
])
expect(linux.container).toBe('ubuntu:20.04')
expect(publishNeeds).toContain('skill-sharing-release-gate')
expect(publishNeeds).toContain('skill-sharing-linux-floor-release-gate')
})
it('runs the focused contract and transaction suite with real Windows coverage', () => {
const platform = workflow.jobs['skill-sharing-release-gate']
const linux = workflow.jobs['skill-sharing-linux-floor-release-gate']
const platformTest = stepNamed(
platform,
'Run skill package, transaction, and compatibility suites'
)
const linuxTest = stepNamed(linux, 'Run skill package, transaction, and compatibility suites')
const command = packageJson.scripts['test:skill-sharing:release']
expect(platformTest.env.ORCA_REAL_WINDOWS_SKILL_TEST).toContain("runner.os == 'Windows'")
expect(platformTest.env.ORCA_REAL_PROCESS_SKILL_TEST).toBe('1')
expect(linuxTest.env.ORCA_REAL_PROCESS_SKILL_TEST).toBe('1')
expect(platformTest.run).toContain('pnpm test:skill-sharing:release')
expect(linuxTest.run).toContain('pnpm test:skill-sharing:release')
expect(command).toContain('src/main/skills')
expect(command).toContain('src/relay/skill-install-handler.test.ts')
expect(command).toContain('src/shared/skill-bundle-install-contract.test.ts')
})
it('archives bounded machine-readable evidence from every platform', () => {
for (const jobName of [
'skill-sharing-release-gate',
'skill-sharing-linux-floor-release-gate'
]) {
const job = workflow.jobs[jobName]
const test = stepNamed(job, 'Run skill package, transaction, and compatibility suites')
const archive = stepNamed(job, 'Archive bounded skill-sharing results')
expect(test.run).toContain('--reporter=json')
expect(test.run).toContain('--outputFile=skill-sharing-release-results.json')
expect(archive.if).toBe('always()')
expect(archive.with['retention-days']).toBe(14)
expect(archive.with['if-no-files-found']).toBe('error')
}
})
it('loads each exact Linux package on the glibc 2.31 floor', () => {
const build = workflow.jobs.build
const smoke = stepNamed(build, 'Load packaged node-pty on the Linux floor')
const linuxEntries = build.strategy.matrix.include.filter(({ platform }) =>
platform.startsWith('linux-')
)
expect(linuxEntries).toEqual([
expect.objectContaining({ platform: 'linux-x64', unpacked_dir: 'dist/linux-unpacked' }),
expect.objectContaining({
platform: 'linux-arm64',
unpacked_dir: 'dist/linux-arm64-unpacked'
})
])
expect(smoke.if).toContain("matrix.platform == 'linux-x64'")
expect(smoke.with.command).toContain('run-linux-packaged-node-pty-floor-smoke.mjs')
expect(smoke.with.command).toContain('${{ matrix.unpacked_dir }}')
})
})
+11
View File
@@ -14,8 +14,13 @@
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
"../src/main/agent-hooks/managed-hook-script-refresh.ts",
"../src/main/agent-hooks/runtime-home-hook-command.ts",
"../src/main/amp/agent-status-plugin-source.ts",
"../src/main/amp/hook-service.ts",
"../src/main/amp/managed-plugin-install-status.ts",
"../src/main/antigravity/hook-events.ts",
"../src/main/antigravity/hook-script.ts",
"../src/main/antigravity/hook-service.ts",
"../src/main/antigravity/hooks-json-bundle.ts",
"../src/main/claude/hook-settings.ts",
"../src/main/claude/hook-service.ts",
"../src/main/claude/statusline-script.ts",
@@ -62,6 +67,9 @@
"../src/main/codex-cli/command.ts",
"../src/main/command-code/command-code-managed-script.ts",
"../src/main/command-code/hook-service.ts",
"../src/main/copilot/copilot-managed-hook-definitions.ts",
"../src/main/copilot/copilot-managed-script.ts",
"../src/main/copilot/copilot-remote-hook-install.ts",
"../src/main/copilot/hook-service.ts",
"../src/main/cursor/hook-service.ts",
"../src/main/droid/hook-service.ts",
@@ -71,6 +79,9 @@
"../src/main/devin/hook-settings.ts",
"../src/main/devin/hook-service.ts",
"../src/main/devin/hook-config-json.ts",
"../src/main/hermes/hermes-config-yaml.ts",
"../src/main/hermes/hermes-home-filesystem.ts",
"../src/main/hermes/hermes-managed-plugin-source.ts",
"../src/main/hermes/hook-service.ts",
"../src/main/kimi/hook-service.ts",
"../src/main/kimi/kimi-hook-config-toml.ts",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 20m">
<title>downloads: 20m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 21m">
<title>downloads: 21m</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="90" y="15" fill="#010101" fill-opacity=".3">20m</text>
<text x="90" y="14">20m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">21m</text>
<text x="90" y="14">21m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 371 KiB

After

Width:  |  Height:  |  Size: 388 KiB

@@ -0,0 +1,97 @@
# Administer agent skill sharing
This guide describes the first-release access, lifecycle, retention, and recovery contract for
Orca skill sharing. The operator runbook remains the source of truth for incident commands and
environment-specific procedures.
## Access model
- Shared bundles are unlisted bearer resources. Orca provides no public browse, search, recipient
inventory, or package index.
- Anyone with an active, unexpired link can inspect the package and request a short-lived download
grant without signing in.
- Publishing, package/version management, owned-link inventory, revocation, and deletion require
an authenticated package owner with current organization access.
- Missing, expired, revoked, deleted, and unauthorized resources return the same non-disclosing
response.
- Desktop and remote runtimes receive no GCP identity or long-lived storage credential.
The durable share ID is a credential. Do not put it in tickets, logs, analytics, or support
bundles. Use revocation if a link may have reached an unintended recipient.
## Revocation and deletion
Revoking a share immediately blocks new resolution and download grants. A generation-bound grant
issued before revocation can work until its five-minute expiry. Already installed skills remain on
recipient machines.
Package deletion follows this order:
1. Mark the package deleted and revoke its active shares.
2. Dereference retained versions transactionally.
3. Delete only an object generation that no retained version references.
4. Reconcile bounded pending deletions after partial database or GCS failures.
A version cannot be deleted while an active pinned share references it. Deletion uses the exact
recorded GCS generation and never overwrites an immutable published key.
## User and organization departure
Packages belong to an owner tenant and record the publishing user. In an organization tenant,
another current member can manage the package after its publisher leaves; Orca does not rewrite
the recorded creator. Removing a user does not automatically revoke the organization's links,
delete packages, or remove installed copies.
Before deleting an organization tenant:
1. Disable new grants for the tenant.
2. Have an authorized operator inventory and revoke active shares.
3. Decide whether packages transfer to another authorized owner, remain retained, or are deleted.
4. Resolve legal hold, erasure, and audit-retention requirements.
5. Apply the coordinated metadata and object lifecycle; do not bypass reference checks.
The product does not yet encode a universal ownership-transfer or legal-retention policy. Privacy,
security, and the organization owner must approve the applicable policy before external rollout.
Until that decision is recorded, preserve metadata and soft-deleted generations rather than
guessing.
## Retention contract
| Data | Default behavior |
| -------------------------------------- | --------------------------------------------------------- |
| Upload policy and pending upload row | Expires after 15 minutes |
| Abandoned `uploads/` quarantine object | Deleted by GCS after one day |
| Published immutable package object | No age-based deletion; retained while referenced |
| Issued download grant | Expires after five minutes |
| Deleted package object | Recoverable through GCS soft delete for seven days |
| PostgreSQL metadata | Covered by backups and seven-day point-in-time recovery |
| Installed recipient copy | Independent local data; Cloud deletion does not remove it |
| Audit event | Follows the approved audit-retention policy |
Organization retention, legal hold, and erasure requirements take precedence over product rollback
retention. Product deletion is not a legal-hold mechanism.
## Audit and privacy
Audit records may include package/version IDs, actor IDs, event category, outcome, and timestamp.
They must not include skill contents, filenames, manifests, organization membership lists, local
paths, durable share URLs, upload policies, download grants, or credentials. Anonymous abuse
controls must not persist raw requester IP addresses.
Normal Cloud logs are limited to route, method, status, duration, and bounded failure categories.
Use seeded privacy canaries when validating staging logs and diagnostic exports.
## Recovery and incident controls
Upload, download, and remote-install operations have independent kill switches. Disable the
narrowest affected operation; existing local discovery and installs continue to work.
Coordinate PostgreSQL point-in-time recovery with GCS generation recovery. Restore metadata into
an isolated database, identify exact referenced generations, restore only matching soft-deleted
objects, verify archive and package identities, then transactionally repoint metadata. Keep grants
disabled until bearer preview and a generation-bound download pass.
See the Orca Cloud `docs/skill-sharing-runbook.md` for deployment controls, reconciliation,
saturation, signing failures, database outages, and the guarded restore workflow. Security
invariants and unresolved release gates are recorded in
[Agent skill sharing threat model](./agent-skill-sharing-threat-model.md).
@@ -0,0 +1,38 @@
# Agent skill provider paths
Last verified: 2026-08-11.
V1 supports only providers whose paths are independently established by official documentation.
The registry is deliberately small; it is not copied or synchronized from a community path table.
| Provider | Detection | Global canonical support | Workspace support | Orca placement |
| --- | --- | --- | --- | --- |
| Codex | `codex` CLI found through Orca's host-owned PATH detection | Reads `$HOME/.agents/skills` directly | Reads `.agents/skills` from the current directory through the repository root | Canonical copy only |
| Claude Code | `claude` CLI found through Orca's host-owned PATH detection | Reads `$HOME/.claude/skills` | Reads `.claude/skills` from the launch directory through the repository root, plus nested directories as files are accessed | Relative directory symlink on POSIX, directory junction on Windows, or verified independent-copy fallback |
Codex locations and symlink behavior are documented in the official OpenAI documentation:
[Build skills](https://learn.chatgpt.com/docs/build-skills#where-codex-loads-local-skills).
Claude Code locations, precedence, parent traversal, live detection, and symlink behavior are
documented in the official Anthropic documentation:
[Extend Claude with skills](https://code.claude.com/docs/en/skills#where-skills-live).
Codex therefore needs no provider-specific placement. Claude Code does not document
`.agents/skills` as a discovery root, so Orca reconciles its documented `.claude/skills` path back
to the canonical copy. Orca never replaces a path it does not own. If alias creation is unavailable,
the verified copy fallback is tracked in the install receipt so update and removal can detect drift.
## Registry change process
Every registry change requires normal code review and all of the following evidence:
1. Link current official provider documentation for global and workspace paths.
2. Record whether the provider reads `.agents/skills` directly and its documented link behavior.
3. Verify global and folder-workspace discovery on macOS, Linux, native Windows, and WSL where the
provider supports those platforms.
4. Exercise local, paired-runtime, and SSH host-owned path resolution.
5. Test alias denial, broken owned aliases, independent-copy drift, update, rollback, and removal.
6. Update mixed-version capability evidence if the placement contract changes.
Do not add automated upstream path-table synchronization. A provider release that changes discovery
semantics must enter through this review process.
@@ -0,0 +1,101 @@
# Agent skill sharing threat model
Status: implementation baseline for security and privacy review. This document does not constitute
security approval.
## Scope and trust model
This model covers private skill packaging, Orca Cloud publication and authorization, durable share
resolution, local and remote installation, provider placement, update, rollback, removal, and
operator recovery. It applies to macOS, Linux, native Windows, WSL, paired Orca runtimes, and SSH
targets.
Private means unlisted: an unpredictable active share ID is a bearer credential, while publication
and management remain access-controlled to authenticated Orca users and organizations. V1 is not
end-to-end encrypted from Orca Cloud operators. A skill is code from its author: `SKILL.md` can
change agent behavior and packaged scripts may be executed later by a user or agent, although the
installer itself never executes package content.
## Protected assets
- Skill contents, filenames, manifests, package metadata, release notes, and author identity.
- Organization membership, selected-user ACLs, durable share identifiers, and package existence.
- Signed upload policies, download grants, authentication tokens, database credentials, and GCP
service identities.
- Existing local skills, provider configuration, user modifications, provenance, and transaction
recovery state.
- Cloud package metadata, immutable object generations, audit records, and deletion state.
- Availability and cost of Cloud Run, Cloud SQL, GCS, connected runtimes, and local filesystems.
## Actors and boundaries
Actors include an authorized publisher, an authorized recipient, an authenticated but unauthorized
Orca user, a malicious skill author, a compromised renderer, an untrusted remote RPC caller, an
attacker controlling a network endpoint, and an operator with GCP or database access.
Trust boundaries are:
1. Source skill directory to the owner-private package staging directory.
2. Desktop renderer to the main process and its authenticated Cloud client.
3. Orca client to a paired runtime or SSH host across independently versioned protocols.
4. Orca Cloud API authorization to short-lived GCS access.
5. GCS quarantine to validated immutable publication and PostgreSQL metadata.
6. Extracted staging to canonical destination and provider placements.
7. Local diagnostic records to a user-reviewed support-bundle upload.
8. Terraform and deployment identities to staging and production resources.
## Security invariants
- Package identity is deterministic and binds normalized paths, exact bytes, executable state, and
immutable package/version IDs.
- Publication and installation accept only the `manifest.json` plus `skill/` envelope and never
execute archive content.
- Archive validation completes before destination mutation.
- Destination paths are resolved by the runtime that owns the host or workspace.
- Unowned or modified local content is never silently replaced or deleted.
- Final GCS objects are immutable, generation-fenced, and reachable only through fresh ACL checks
followed by short-lived grants.
- No remote caller can turn a desktop-local path into remote filesystem authority.
- Interrupted transactions converge to a verified old or requested version.
- Logs and support bundles exclude package contents and private authorization or filesystem data.
- Cloud sharing, downloading, and remote installation have independent kill switches.
## Threat register
| ID | Threat | Required controls and current evidence | Residual release gate |
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| TM-01 | Source changes after preview or a link swaps bytes during packaging. | Observe the specific source directory, copy without following links, re-observe staged bytes, compare every identity, and bind preview to the final digest. Source-drift and link/special-file tests cover rejection and cleanup. | Repeat race and permission tests on every supported filesystem. |
| TM-02 | Archive traversal, drive paths, links, devices, duplicate paths, Unicode/case collisions, or decompression/resource exhaustion escape staging. | Streaming parser rejects unsafe entry classes and normalized collisions; compressed, extracted, entry, file, depth, and per-file limits are enforced during parsing and extraction. Boundary, malformed, checksum, and fuzz tests run before destination mutation. | Keep package safety suites required in release CI. |
| TM-03 | A forged manifest lies about names, bytes, executable state, or package identity. | Parse the manifest before trusting entries, require `skill/SKILL.md`, hash extracted bytes independently, recompute package identity, and compare package/version IDs and digest. | Cross-platform identical-byte digest evidence remains required. |
| TM-04 | A client chooses another home, workspace, WSL distro, SSH path, or escapes a destination root. | The executing runtime resolves home and workspace identities, realpath-checks directories, uses platform-native joins, and rejects client-supplied remote paths. `local-file` ingress is trusted in-process only. | Real SSH and mixed-version topology tests remain required. |
| TM-05 | Installation overwrites or deletion removes unowned or locally modified content. | Planner distinguishes missing, unchanged, clean, modified, unowned, external-link, broken-link, and collision states. Provenance lives outside installed content. Replacement requires an explicit conflict decision; removal revalidates ownership and digest. | Complete the remaining real junction, external-link, copy-drift, and permission matrix. |
| TM-06 | A crash between renames or receipt publication loses both old and new versions. | Same-filesystem staging, durable journals, backups, flushed receipt replacement, bounded recovery, and ownership tokens protect every commit boundary. Failure injection covers every journal transition. | Real process termination and disk/antivirus contention tests remain required. |
| TM-07 | A grant leaks through redirects, userinfo, an insecure scheme, DNS/host confusion, or an oversized stream. | Cloud requests reject redirects. Package downloads require configured origins and HTTPS, reject URL credentials and cross-origin redirects, cap redirect count, recheck expiry, stream exact expected bytes, and verify archive/package digests. | Validate approved production origins and exercise malicious network cases in staging. |
| TM-08 | Reuse of an upload ID, wrong tenant, wrong key, stale generation, or quarantine object publishes attacker-selected bytes. | Random tenant-bound upload rows, signed POST conditions, expiry, exact metadata/key/type/size validation, generation-fenced reads, streamed validation, and idempotent finalization fail closed. | Staging GCS integration must cover stale generations and lifecycle deletion. |
| TM-09 | IDOR, stale organization membership, or a guessed identifier exposes package management data or bearer-protected content. | Management requests authenticate and re-evaluate current ownership. Recipient preview and grants require the exact unpredictable, active bearer share ID and ignore legacy ACL rows. Not-found responses hide unauthorized existence. | Human authorization review and organization-departure policy approval remain required. |
| TM-10 | Content-addressed deduplication discloses another tenant's package through response shape or timing. | Tenant-scoped APIs never expose GCS keys, generations, or whether an object already existed; existing-object reuse verifies both archive and logical package identity. Cross-tenant tests prove identical archives use separate tenant-hashed objects and response shapes disclose no reuse. | Keep tenant-isolation and response-disclosure coverage required in release CI. |
| TM-11 | A compromised renderer, old client, or arbitrary RPC caller sends credentials, local paths, unknown opcodes, or unsupported operations to a host. | Main owns auth tokens and grants; remote requests use strict schemas and capabilities; package transfer is separate from install; no stream opcode was added; mixed versions fail with update-required results. | Real client-newer/server-newer and SSH parity gates remain required. |
| TM-12 | Transfer replay, overlap, disconnect, or abandoned staging consumes disk or commits different bytes. | Session count, idle time, total bytes, and chunk size are bounded. Offsets are monotonic, identical retry is idempotent, changed replay fails, commit hashes the exact staged file, and cancellation/disconnect cleanup is bounded. | Exercise offline-GCS and real disconnects at every transfer boundary. |
| TM-13 | Provider aliases or junctions escape canonical storage or point at external content. | POSIX aliases are relative from real parents, Windows junctions use absolute canonical targets, targets are revalidated, and unowned/broken/external links are conflicts. Copy fallback is independently hashed and receipt-owned. Real Windows and WSL coverage includes existing, broken, external, denied, and drifted placement behavior. | Preserve the physical placement matrix in release coverage. |
| TM-14 | Skill instructions or scripts are mistaken for trusted Orca code or executed during install. | Share/install previews identify author, organization, scripts, executable files, digest, and version. Installation never runs scripts. Trust copy says the package is code from its author. | Security and design must approve the final trust wording and accessibility behavior. |
| TM-15 | Telemetry, logs, or support bundles leak instructions, filenames, paths, ACLs, grants, policies, or credentials. | Desktop install diagnostics map values to bounded categories before tracing. Deployed staging logs contain route templates and bounded request metadata only, and the logging exclusion removes bearer URLs. Support-bundle tests inject private canaries and prove they are absent from collected output. | Preserve deployed-log and support-bundle privacy checks for future changes. |
| TM-16 | Permissive staging permissions expose package bytes to another local user. | Package archives, downloads, extraction, relayed uploads, locks, journals, and receipts use owner-private modes on POSIX; Windows uses owner-profile paths and inherited ACLs. Existing POSIX download roots are tightened before use. Real Windows, WSL, Ubuntu-floor, and SSH validation passed. | Preserve owner-private staging checks across supported hosts. |
| TM-17 | Deletion, revocation, retention, or user departure leaves unauthorized grants or unrecoverable metadata/blob divergence. | Revocation blocks new grants immediately; existing grants expire within five minutes. Database references govern final deletion, quarantine lifecycle cleans abandonment, and GCS soft delete supplies recovery. Local installs remain independent. | Approve departure/legal-retention policy and exercise coordinated database/GCS recovery. |
| TM-18 | Broad IAM, public bucket access, service-account keys, or a compromised deployment identity bypasses application authorization. | Uniform bucket access, public-access prevention, bucket-scoped object access, service-account-scoped signing, skill-secret-only access, Cloud SQL client role, no desktop IAM, and no long-lived keys are Terraform-defined. | Review the staging and production plans and verify live IAM before rollout. |
| TM-19 | Unbounded validation or request concurrency causes memory, CPU, database, storage, or egress denial of service. | Fixed streaming buffers, package limits, per-instance finalization semaphore, rate/quota limits, bounded transfer sessions, Cloud Run instance limits, lifecycle cleanup, and independent kill switches constrain work. | Complete load testing, dashboards, alerts, and budget thresholds. |
| TM-20 | Operator recovery, diagnostics, or legal workflows bypass tenant isolation or leak content. | Runbooks require generation-specific recovery, coordinated PostgreSQL/GCS restoration, audited lifecycle actions, and no package contents in normal logs. | Security/privacy approval and restricted break-glass procedure remain required. |
## Required review evidence
Security and privacy approval must not rely on this document alone. Reviewers need:
- Package/admission schemas and stable failure categories.
- Archive parser, extraction containment, transaction, provenance, and removal tests.
- Cloud authorization, object-generation, tenant-isolation, deletion, and recovery tests.
- Terraform plans plus live staging IAM, bucket, Cloud Run, Secret Manager, and Cloud SQL evidence.
- Real macOS, Linux-floor, Windows, WSL, paired-runtime, mixed-version, and SSH results.
- Captured staging logs, metrics, traces, and support bundles with seeded private canaries absent.
- Load-test results and independently tested upload, download, and remote-install kill switches.
Approval owners record findings and accepted residual risks outside this implementation document.
The external rollout gate remains closed until those findings are resolved or explicitly accepted.
@@ -0,0 +1,43 @@
# Agent skill sharing upstream boundary
Status: proposed for formal engineering and legal review.
Date: 2026-08-11.
## Context
Orca needs private, durable, cross-machine skill sharing with bounded archive ingestion,
host-owned destination resolution, crash-safe transactions, provenance, and mixed-version remote
support. `vercel-labs/skills` exposes a CLI and does not provide the Cloud authorization or local
transaction contract Orca requires.
The behavioral assessment used upstream commit
`c6f69c631292444cc541ac6d91e2226b0ff247da`.
## Decision
Orca implements its package, Cloud, installation, provider-placement, and recovery behavior
independently. The upstream project is a behavioral reference only.
Do not copy or mechanically translate upstream source, tests, fixtures, registry entries, provider
path tables, comments, or documentation. Derive provider paths from official provider
documentation and verify them with real installations. Orca does not depend on the upstream CLI,
npm package, or an unsupported programmatic API.
If a future change proposes incorporating upstream material, stop and review the exact material,
license, attribution, notices, and maintenance implications before implementation or merge.
## Consequences
- Orca owns stability, security, compatibility, and maintenance of this narrower installer.
- There is no automatic upstream synchronization job.
- Similar behavior is acceptable when independently derived from requirements and official
provider contracts; textual or structural copying is not.
- Provider registry changes require normal code review plus official-documentation and real-host
evidence.
- The pull request template requires reviewers to confirm this boundary for relevant changes.
## Review record
Product chose the reference-only approach during planning. Formal engineering and legal reviewers
remain to be named before external rollout.
@@ -0,0 +1,20 @@
# Relay regional placement
Orca selects a Relay region in the Electron main process before requesting a new assignment. The
director publishes an allowlisted region catalog containing only HTTPS cell subdomains of that
director; Orca takes three bounded `/health` latency samples per region and caches the stable choice
for 24 hours. A cached region changes only when the alternative is materially faster.
The assignment request sends only `preferredRegion`. It does not send latency, IP address, country,
pairing data, or credentials. Catalog, probe, and cache failures fall back to an assignment without
a region preference. A rolled-back director that rejects the new field is retried once without
only that field while preserving reconnect behavior.
The selection measures the desktop network path. Folder workspaces and SSH workspaces share the
same local broker and do not run probes on remote hosts. The phone continues to connect to the
exact cell URL in the desktop pairing payload, so its location is not measured independently and
no mobile protocol update is required.
For deterministic local diagnostics, set `ORCA_RELAY_REGION_OVERRIDE` to `us-central1` or
`asia-east2` before launching Orca. The override is not an end-user setting and is not written to
the preference cache.
+88
View File
@@ -0,0 +1,88 @@
# Share and install agent skills
Orca can put one skill or a bundle of skills behind one unlisted, revocable link. Shared bundles
do not appear in search, a catalog, or a public index. Anyone who has an active link can inspect
and install its contents without signing in, so treat the link like a credential.
## Share skills
Publishing and link management require an Orca account in the desktop app.
1. Open **Skills** and choose **Share skills**.
2. Select one or more skills. One link can contain a large collection, such as 30 skills.
3. Review the bundle name, included skills and files, scripts, executable files, digest, account,
and optional release notes.
4. Choose **Publish skill**, **Publish bundle**, or **Publish new version**, then copy the link.
Orca publishes an immutable version. Later changes do not silently alter a link's current bytes;
publish a new version to update the Cloud package.
Use **Settings → Share Skills** to copy or revoke active links. Revocation blocks new previews and
download grants. A grant issued immediately before revocation can remain usable for up to five
minutes, and revocation does not remove copies that recipients already installed.
## Install from a link
Opening an Orca skill link shows a preview before changing any files. You can also open **Skills**,
choose **Install from link**, and paste the URL.
1. Verify the author and organization.
2. Review the version, release notes, included skills, scripts, executable files, and digest.
3. Select all skills or only the ones you want.
4. Choose the destination machine and either global or workspace scope.
5. Review new, unchanged, updated, and conflicting skills, then choose **Install N skills**.
Supported destinations include the local machine, paired Orca runtimes, WSL, and SSH hosts. The
destination runtime resolves its own home and workspace paths, so folder workspaces and remote
filesystems do not borrow paths from the client machine.
Orca keeps one canonical installed copy and places it where supported agents can discover it.
Current provider coverage is documented in
[Agent skill provider paths](./agent-skill-provider-paths.md).
## Conflicts, updates, and rollback
**Keep local** is the default when an existing skill differs. Orca replaces modified content only
after you explicitly choose to discard it.
Open **Skills → Manage installs** to inspect managed skills and their immutable version history.
Installing the latest version performs an update; selecting an older retained version performs a
rollback. Both use the same protected install transaction. If a bundle changes between versions,
Orca updates only the selected skills that still exist in that version.
An interrupted install is recovered on restart. If Orca reports a conflict or partial result,
review the named skill and retry; completed skills do not need to be installed again.
## Remove an installed skill
Use **Skills → Manage installs → Remove**. Orca removes only copies and provider placements that it
owns and can verify. Modified or unowned files are preserved and reported. Discarding modified
content requires a separate explicit confirmation.
Removing a local install does not revoke its share or delete its Cloud package. Likewise,
revoking or deleting Cloud data does not reach into recipients' machines.
## Retention and deletion
- Upload grants expire after 15 minutes.
- Abandoned upload bytes are removed from quarantine after one day.
- Published versions have no automatic age-based deletion.
- Deleting a package revokes its links before unreferenced objects are deleted.
- Deleted GCS objects remain operator-recoverable through a seven-day soft-delete window.
- Installed copies remain until someone removes them on each destination machine.
Organization legal or retention requirements can override normal rollback and deletion timing.
## Trust and privacy
A skill is code from its author. `SKILL.md` can change agent behavior, and included scripts or
executables may run later when a person or agent uses the skill. Orca validates the package and
never executes its contents during installation, but you should install only from people you trust
and review unexpected scripts or executable files.
Orca records bounded operational identifiers and outcomes. Normal logs, telemetry, and support
bundles exclude skill contents, filenames, manifests, local paths, share URLs, upload policies,
download grants, credentials, and access lists.
If a link no longer works, ask its owner for an active link. Missing, expired, revoked, and deleted
links intentionally show the same response so Orca does not disclose private package existence.
+2 -2
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.43",
"version": "0.0.44",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -75,7 +75,7 @@
"allowBackup": false,
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
"package": "com.stably.orca.mobile",
"versionCode": 12
"versionCode": 13
},
"plugins": [
"expo-router",
+4 -1
View File
@@ -32,6 +32,7 @@ import { useWorktreeResync } from '../../../src/transport/use-worktree-resync'
import { startHostWorktreeRefresh } from '../../../src/worktree/host-worktree-refresh'
import {
useLastConnectedAt,
usePendingConnectionPath,
useReconnectAttempt
} from '../../../src/transport/client-context-connection-metrics'
import {
@@ -138,6 +139,7 @@ export function HostScreen({
const { client, state: connState } = useHostClient(hostId)
const reconnectAttempts = useReconnectAttempt(hostId)
const lastConnectedAt = useLastConnectedAt(hostId)
const pendingConnectionPath = usePendingConnectionPath(hostId)
const clientRef = useRef<RpcClient | null>(null)
const fetchWorktreesInFlightRef = useRef(false)
// Why: useRef, not useMemo — React may discard memoized values, which would silently
@@ -817,7 +819,8 @@ export function HostScreen({
const headerVerdict = classifyConnection({
state: connState,
reconnectAttempts,
lastConnectedAt
lastConnectedAt,
pendingPath: pendingConnectionPath
})
return (
<>
+62 -20
View File
@@ -61,6 +61,7 @@ import {
import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context'
import {
useLastConnectedAt,
usePendingConnectionPath,
useReconnectAttempt
} from '../../../../src/transport/client-context-connection-metrics'
import {
@@ -207,7 +208,6 @@ import {
confirmsMirroredTabSelection,
type AppliedSnapshotMarker
} from '../../../../src/session/session-tab-snapshot-gate'
import { hasPendingTerminalHandleRecoveryNeed } from '../../../../src/session/pending-terminal-handle-recovery'
import {
createInitialSessionAutoCreateState,
useInitialSessionTerminalAutoCreate,
@@ -254,6 +254,7 @@ import type {
} from '../../../../src/session/mobile-session-tabs-stream-health'
import { useMobileSessionTabsFetchReporting } from '../../../../src/session/use-mobile-session-tabs-fetch-reporting'
import { useMobileSessionTabsReconciliation } from '../../../../src/session/use-mobile-session-tabs-reconciliation'
import { PendingTerminalHandleRecoveryContextCache } from '../../../../src/session/pending-terminal-handle-recovery'
import {
getRepoIdFromMobileWorktreeId,
getActiveTabIdForHandle,
@@ -736,6 +737,7 @@ export default function SessionScreen() {
const { client, state: connState } = useHostClient(hostId)
const reconnectAttempts = useReconnectAttempt(hostId)
const lastConnectedAt = useLastConnectedAt(hostId)
const pendingConnectionPath = usePendingConnectionPath(hostId)
const forceReconnectHost = useForceReconnect()
const { name: worktreeName, resolution: worktreeResolution } = useLiveWorktreeName({
client,
@@ -936,6 +938,9 @@ export default function SessionScreen() {
const pendingBrowserFocusPageIdRef = useRef<string | null>(null)
const switchSessionTabRef = useRef<((tab: MobileSessionTab) => void) | null>(null)
const pendingTerminalActivationAttemptRef = useRef<string | null>(null)
const [parkedPendingTerminalContext, setParkedPendingTerminalContext] = useState<string | null>(
null
)
// Why: route the terminal URL tap through a ref so it runs the current handleCreateBrowser closure (the memoized one may hold a null-client render).
const handleCreateBrowserRef = useRef<((rawUrl?: string) => Promise<boolean>) | null>(null)
@@ -2284,16 +2289,25 @@ export default function SessionScreen() {
() =>
closedTabTombstonesRef.current.size > 0 ||
pendingBrowserFocusPageIdRef.current !== null ||
// Why: a pending-handle terminal only turns ready in a fresh snapshot. A live
// stream otherwise parks the poll, and a host that mints the handle without
// republishing strands the pane on its spinner forever (STA-4256).
hasPendingTerminalHandleRecoveryNeed(sessionTabsRef.current, activeSessionTabIdRef.current) ||
// Why: a chat-covered handle that ran out of rearms and left `terminal.list`
// was reminted by a desktop graph reload. Only a fresh tab snapshot carries
// the replacement handle, so force one instead of holding the composer locked.
nativeChatStream.hasTabsRecoveryNeed(),
[nativeChatStream]
)
const pendingTerminalRecoveryContextCache = useMemo(
() => new PendingTerminalHandleRecoveryContextCache(),
[]
)
const getPendingTerminalRecoveryContextKey = useCallback(
() =>
pendingTerminalRecoveryContextCache.read(
sessionTabsRef.current,
activeSessionTabIdRef.current
),
[pendingTerminalRecoveryContextCache]
)
const pendingTerminalRecoveryContextKey = getPendingTerminalRecoveryContextKey()
const getSessionTabsApplicationRevision = useCallback(
() => appliedSessionTabsRevisionRef.current,
[]
@@ -2302,18 +2316,25 @@ export default function SessionScreen() {
worktreeId,
diagnosticsRef: terminalDiagnosticsRef
})
const { fetchSessionTabs, ensureSessionTabs, fetchPendingBrowserSessionTabs } =
useMobileSessionTabsReconciliation<SessionTabsResult, MobileSessionTab>({
client,
connState,
worktreeId,
applySessionTabs,
consumeAcceptedSessionTabs,
fetchTerminals,
hasRecoveryNeed: hasSessionTabsRecoveryNeed,
getApplicationRevision: getSessionTabsApplicationRevision,
...sessionTabsFetchReporting
})
const {
fetchSessionTabs,
ensureSessionTabs,
fetchPendingBrowserSessionTabs,
retryPendingTerminalRecovery
} = useMobileSessionTabsReconciliation<SessionTabsResult, MobileSessionTab>({
client,
connState,
worktreeId,
applySessionTabs,
consumeAcceptedSessionTabs,
fetchTerminals,
hasRecoveryNeed: hasSessionTabsRecoveryNeed,
pendingTerminalRecoveryContextKey,
getPendingTerminalRecoveryContextKey,
onPendingTerminalRecoveryParked: setParkedPendingTerminalContext,
getApplicationRevision: getSessionTabsApplicationRevision,
...sessionTabsFetchReporting
})
useEffect(() => {
if (connState === 'connected') {
@@ -4075,6 +4096,9 @@ export default function SessionScreen() {
activeSessionTab?.type === 'terminal' && typeof activeSessionTab.terminal !== 'string'
? activeSessionTab
: null
const isPendingTerminalRecoveryParked =
pendingTerminalRecoveryContextKey !== null &&
pendingTerminalRecoveryContextKey === parkedPendingTerminalContext
useEffect(() => {
if (!client || connState !== 'connected' || !activePendingTerminalTab) {
@@ -4149,7 +4173,8 @@ export default function SessionScreen() {
state: connState,
reconnectAttempts,
lastConnectedAt,
endpoint: hostEndpoint
endpoint: hostEndpoint,
pendingPath: pendingConnectionPath
})
const showConnectionRetry =
connectionVerdict.kind === 'warning' || connectionVerdict.kind === 'unreachable'
@@ -4593,10 +4618,27 @@ export default function SessionScreen() {
</View>
) : activePendingTerminalTab ? (
<View style={styles.emptyState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
{!isPendingTerminalRecoveryParked && (
<ActivityIndicator size="small" color={colors.textSecondary} />
)}
<Text style={styles.emptyText}>
{activePendingTerminalTab.title || 'Loading terminal'}
{isPendingTerminalRecoveryParked
? 'Terminal is taking longer than expected'
: activePendingTerminalTab.title || 'Loading terminal'}
</Text>
{isPendingTerminalRecoveryParked && (
<Pressable
accessibilityRole="button"
accessibilityLabel="Retry loading terminal"
style={({ pressed }) => [
styles.createButton,
pressed && styles.newTerminalButtonPressed
]}
onPress={() => void retryPendingTerminalRecovery()}
>
<Text style={styles.createButtonText}>Retry</Text>
</Pressable>
)}
</View>
) : (
<View
+4 -1
View File
@@ -37,6 +37,7 @@ import type { RpcSuccess } from '../../../src/transport/types'
import { useHostClient } from '../../../src/transport/client-context'
import {
useLastConnectedAt,
usePendingConnectionPath,
useReconnectAttempt
} from '../../../src/transport/client-context-connection-metrics'
import { classifyConnection } from '../../../src/transport/connection-health'
@@ -2073,6 +2074,7 @@ export default function MobileTasksScreen() {
const { client, state: connState } = useHostClient(hostId)
const reconnectAttempts = useReconnectAttempt(hostId)
const lastConnectedAt = useLastConnectedAt(hostId)
const pendingConnectionPath = usePendingConnectionPath(hostId)
const clientRef = useRef<RpcClient | null>(null)
const loadGenerationRef = useRef(0)
const taskResumeRef = useRef<TaskResumeState>({})
@@ -8673,7 +8675,8 @@ export default function MobileTasksScreen() {
const headerVerdict = classifyConnection({
state: connState,
reconnectAttempts,
lastConnectedAt
lastConnectedAt,
pendingPath: pendingConnectionPath
})
const emptyLabel =
connState !== 'connected'
+6 -1
View File
@@ -259,6 +259,10 @@ export default function HomeScreen() {
() => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])),
[allClients]
)
const hostPendingPaths = useMemo(
() => Object.fromEntries(allClients.map(({ hostId, pendingPath }) => [hostId, pendingPath])),
[allClients]
)
const disconnectHostClient = useDisconnectHostClient()
const forgetHostClient = useForgetHostClient()
const forceReconnectHost = useForceReconnect()
@@ -765,7 +769,8 @@ export default function HomeScreen() {
state,
reconnectAttempts: attempts,
lastConnectedAt,
endpoint: item.endpoint
endpoint: item.endpoint,
pendingPath: hostPendingPaths[item.id] ?? null
})
return (
<MobileHostCard
+8 -8
View File
@@ -5606,8 +5606,8 @@ packages:
multitars@1.0.0:
resolution: {integrity: sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg==}
nanoid@3.3.17:
resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==}
nanoid@3.3.18:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -9906,7 +9906,7 @@ snapshots:
'@react-navigation/routers': 7.5.3
escape-string-regexp: 4.0.0
fast-deep-equal: 3.1.3
nanoid: 3.3.17
nanoid: 3.3.18
query-string: 7.1.3
react: 19.2.6
react-is: 19.2.6
@@ -9942,14 +9942,14 @@ snapshots:
'@react-navigation/core': 7.17.2(react@19.2.6)
escape-string-regexp: 4.0.0
fast-deep-equal: 3.1.3
nanoid: 3.3.17
nanoid: 3.3.18
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)
use-latest-callback: 0.2.6(react@19.2.6)
'@react-navigation/routers@7.5.3':
dependencies:
nanoid: 3.3.17
nanoid: 3.3.18
'@rolldown/binding-android-arm64@1.1.3':
optional: true
@@ -12166,7 +12166,7 @@ snapshots:
expo-symbols: 55.0.8(expo-font@55.0.8)(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
fast-deep-equal: 3.1.3
invariant: 2.2.4
nanoid: 3.3.17
nanoid: 3.3.18
query-string: 7.1.3
react: 19.2.6
react-fast-compare: 3.2.2
@@ -13866,7 +13866,7 @@ snapshots:
multitars@1.0.0: {}
nanoid@3.3.17: {}
nanoid@3.3.18: {}
natural-compare@1.4.0: {}
@@ -14162,7 +14162,7 @@ snapshots:
postcss@8.5.25:
dependencies:
nanoid: 3.3.17
nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -94,22 +94,23 @@ describe('MobileHostCard', () => {
it('names the relay while the dial is still in flight', async () => {
const lines = await renderCard(undefined, {
state: 'connecting',
verdict: { kind: 'normal', label: 'Connecting…' },
verdict: { kind: 'normal', label: 'Connecting via Relay…' },
path: 'relay'
})
expect(lines).toContain('Connecting…')
expect(lines).toContain(' · Orca Relay')
expect(lines).toContain('Connecting via Relay…')
expect(lines).not.toContain(' · Orca Relay')
})
it('names the relay while a failed direct dial is still retrying', async () => {
const lines = await renderCard(undefined, {
state: 'reconnecting',
verdict: { kind: 'normal', label: 'Reconnecting…' },
verdict: { kind: 'normal', label: 'Connecting via Relay…' },
path: 'relay'
})
expect(lines).toContain(' · Orca Relay')
expect(lines).toContain('Connecting via Relay…')
expect(lines).not.toContain(' · Orca Relay')
})
it('leaves an idle disconnected host unlabelled', async () => {
+1 -6
View File
@@ -25,11 +25,6 @@ export function MobileHostCard(props: {
const credentialUnavailable = props.credentialStatus === 'temporarily-unavailable'
const credentialMissing = props.credentialStatus === 'missing'
const connected = props.state === 'connected' && !credentialUnavailable && !credentialMissing
// Why: a relay dial can run for seconds behind "Connecting…"/"Reconnecting…"; naming the
// path mid-wait tells the user the phone is off-LAN rather than hung (F5). Only 'relay' is
// named — 'lan' doubles as the unknown-path default, so it would be a guess before connect.
const dialingPath =
['connecting', 'handshaking', 'reconnecting'].includes(props.state) && props.path === 'relay'
const isError =
credentialMissing || ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind)
const statusLabel = credentialMissing
@@ -44,7 +39,7 @@ export function MobileHostCard(props: {
: props.verdict
const worktreeSummary = homeHostWorktreeSummary(props.worktreeInfo)
const connectionPathLabel =
!credentialMissing && !credentialUnavailable && (connected || dialingPath)
!credentialMissing && !credentialUnavailable && connected
? mobileConnectionPathLabel(props.path)
: null
const discoveryHint =
@@ -187,16 +187,38 @@ describe('mobile session startup', () => {
)
})
it('counts a pending-handle active terminal as a tabs recovery need (STA-4256)', () => {
const recoveryNeed = sliceBetween(
'const hasSessionTabsRecoveryNeed = useCallback(',
'const getSessionTabsApplicationRevision ='
it('wires pending-handle recovery through its bounded context (STA-4256)', () => {
const applySessionTabs = sliceBetween(
'const applySessionTabs = useCallback(',
'const consumeAcceptedSessionTabs = useCallback('
)
const recoveryContext = sliceBetween(
'const pendingTerminalRecoveryContextCache = useMemo(',
'const getSessionTabsApplicationRevision'
)
expect(recoveryNeed).toContain('hasPendingTerminalHandleRecoveryNeed(')
expect(recoveryNeed).toContain('sessionTabsRef.current')
expect(recoveryNeed).toContain('activeSessionTabIdRef.current')
// The predicate only reaches the poll loop through this hook's option.
const tabsRefWrite = 'sessionTabsRef.current = nextTabs'
const tabsStateWrite = 'setSessionTabs((prev)'
const activeRefWrite = 'activeSessionTabIdRef.current = active?.id ?? null'
const activeStateWrite = 'setActiveSessionTabId(active?.id ?? null)'
for (const write of [tabsRefWrite, tabsStateWrite, activeRefWrite, activeStateWrite]) {
expect(applySessionTabs).toContain(write)
}
expect(applySessionTabs.indexOf(tabsRefWrite)).toBeLessThan(
applySessionTabs.indexOf(tabsStateWrite)
)
expect(applySessionTabs.indexOf(activeRefWrite)).toBeLessThan(
applySessionTabs.indexOf(activeStateWrite)
)
expect(recoveryContext).toContain('() => new PendingTerminalHandleRecoveryContextCache()')
expect(recoveryContext).toContain('sessionTabsRef.current,')
expect(recoveryContext).toContain('activeSessionTabIdRef.current')
expect(recoveryContext).toContain(
'const pendingTerminalRecoveryContextKey = getPendingTerminalRecoveryContextKey()'
)
expect(source).toContain('hasRecoveryNeed: hasSessionTabsRecoveryNeed')
expect(source).toContain('getPendingTerminalRecoveryContextKey,')
expect(source).toContain('onPendingTerminalRecoveryParked: setParkedPendingTerminalContext')
expect(source).toContain('retryPendingTerminalRecovery()')
})
})
@@ -134,6 +134,29 @@ describe('MobileSessionTabsStreamHealth', () => {
expect(harness.apply).toHaveBeenCalledTimes(2)
})
it('coalesces repeated retries and releases the retry cohort after failure', async () => {
const harness = makeHarness()
harness.controller.setReconciliationActive(true)
const halfOpen = harness.controller.requestReconciliation()
const retry = harness.controller.retryReconciliation()
const repeatedRetry = harness.controller.retryReconciliation()
expect(harness.sendRequest).toHaveBeenCalledTimes(2)
expect(repeatedRetry).toBe(retry)
harness.requests[1]!.resolve(failure())
await retry
const nextRetry = harness.controller.retryReconciliation()
expect(harness.sendRequest).toHaveBeenCalledTimes(3)
expect(nextRetry).not.toBe(retry)
harness.requests[2]!.resolve(success(result(3)))
await nextRetry
harness.requests[0]!.resolve(success(result(1)))
await halfOpen
expect(harness.apply).toHaveBeenCalledTimes(1)
})
it('starts distinct pre- and post-snapshot lists and discards the stale barrier', async () => {
const harness = makeHarness()
harness.controller.setReconciliationActive(true)
@@ -19,6 +19,7 @@ type RequestOwner = {
type RequestCohort = {
promise: Promise<void>
resolve: () => void
retry: boolean
}
type ControllerOptions<Result, Tab> = {
@@ -31,6 +32,7 @@ type ControllerOptions<Result, Tab> = {
source: SessionTabsStreamSource
) => void
hasRecoveryNeed: () => boolean
allowRecoveryPoll?: () => boolean
getApplicationRevision?: () => number
onFetchStarted?: () => void
onFetchSucceeded?: (result: Result) => void
@@ -84,16 +86,35 @@ export class MobileSessionTabsStreamHealth<Result, Tab> {
return this.requestReconciliation()
}
retryReconciliation(): Promise<void> {
this.syncGeneration()
const currentRequest = this.inFlight.get(`${this.generation}:${this.barrier}`)
if (currentRequest?.retry) {
return currentRequest.promise
}
this.requirementRevision += 1
this.barrier += currentRequest ? 1 : 0
return this.startCurrentRequest(true)
}
poll(): Promise<void> | null {
this.syncGeneration()
if (
!this.reconciliationActive ||
(this.health === 'live' &&
!this.options.hasRecoveryNeed() &&
this.requirementRevision <= this.satisfiedRevision)
) {
if (!this.reconciliationActive) {
return null
}
const recoveryNeeded = this.options.hasRecoveryNeed()
if (this.health === 'live') {
if (!recoveryNeeded && this.requirementRevision <= this.satisfiedRevision) {
return null
}
const currentRequest = this.inFlight.get(`${this.generation}:${this.barrier}`)
if (currentRequest) {
return currentRequest.promise
}
if (recoveryNeeded && this.options.allowRecoveryPoll?.() === false) {
return null
}
}
return this.ensureReconciliation()
}
@@ -186,7 +207,7 @@ export class MobileSessionTabsStreamHealth<Result, Tab> {
this.requirementRevision += 1
}
private startCurrentRequest(): Promise<void> {
private startCurrentRequest(retry = false): Promise<void> {
if (this.disposed || !this.reconciliationActive) {
return Promise.resolve()
}
@@ -199,7 +220,7 @@ export class MobileSessionTabsStreamHealth<Result, Tab> {
const promise = new Promise<void>((resolve) => {
resolveRequest = resolve
})
const cohort = { promise, resolve: resolveRequest }
const cohort = { promise, resolve: resolveRequest, retry }
this.inFlight.set(key, cohort)
this.runCohortRequest(key, cohort)
return promise
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import type { MobileSessionTab } from './mobile-session-route-types'
import { PendingTerminalHandleRecoveryContextCache } from './pending-terminal-handle-recovery'
function terminalTab(id: string, terminal: string | null): MobileSessionTab {
return {
type: 'terminal',
id,
title: 'zsh',
parentTabId: 'parent',
leafId: 'leaf',
status: terminal === null ? 'pending-handle' : 'ready',
terminal,
isActive: true
}
}
describe('PendingTerminalHandleRecoveryContextCache', () => {
it('reads authoritative tab refs before the next render', () => {
const cache = new PendingTerminalHandleRecoveryContextCache()
const pendingTabs = [terminalTab('terminal-a', null)]
const readyTabs = [terminalTab('terminal-a', 'pty-a')]
expect(cache.read(pendingTabs, 'terminal-a')).not.toBeNull()
expect(cache.read(readyTabs, 'terminal-a')).toBeNull()
expect(cache.read(pendingTabs, null)).toBeNull()
expect(cache.read(pendingTabs, 'terminal-a')).not.toBeNull()
})
it('recomputes when the pending terminal identity changes', () => {
const cache = new PendingTerminalHandleRecoveryContextCache()
const first = terminalTab('terminal-a', null)
const parentChanged = { ...first, parentTabId: 'other-parent' }
const leafChanged = { ...first, leafId: 'other-leaf' }
const collisionLeft = { ...first, parentTabId: 'ab', leafId: 'c' }
const collisionRight = { ...first, parentTabId: 'a', leafId: 'bc' }
const firstKey = cache.read([first], first.id)
expect(cache.read([parentChanged], parentChanged.id)).not.toBe(firstKey)
expect(cache.read([leafChanged], leafChanged.id)).not.toBe(firstKey)
const collisionLeftKey = cache.read([collisionLeft], collisionLeft.id)
expect(cache.read([collisionRight], collisionRight.id)).not.toBe(collisionLeftKey)
})
it('does not rescan an unchanged context on the polling hot path', () => {
let skippedTabIdReads = 0
const skippedTab = terminalTab('terminal-b', 'pty-b')
Object.defineProperty(skippedTab, 'id', {
get: () => {
skippedTabIdReads += 1
return 'terminal-b'
}
})
const tabs = [skippedTab, terminalTab('terminal-a', null)]
const cache = new PendingTerminalHandleRecoveryContextCache()
const contextKey = cache.read(tabs, 'terminal-a')
const readsAfterFirstLookup = skippedTabIdReads
expect(readsAfterFirstLookup).toBeGreaterThan(0)
expect(cache.read(tabs, 'terminal-a')).toBe(contextKey)
expect(skippedTabIdReads).toBe(readsAfterFirstLookup)
})
})
@@ -0,0 +1,371 @@
import { createElement, Suspense } from 'react'
import { act, create } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import type { SessionTabsApplyOutcome } from './mobile-session-tabs-stream-health'
import { PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS } from './pending-terminal-handle-recovery'
import { useMobileSessionTabsReconciliation } from './use-mobile-session-tabs-reconciliation'
type TestResult = {
type?: 'snapshot' | 'updated'
snapshotVersion: number
tabs: string[]
}
type HarnessProps = {
onParked?: (key: string | null) => void
suspend?: Promise<never>
}
const lifecycle = vi.hoisted(() => ({
appState: 'active',
focused: true,
listeners: new Set<(state: string) => void>()
}))
vi.mock('react-native', () => ({
AppState: {
get currentState() {
return lifecycle.appState
},
addEventListener(_event: string, listener: (state: string) => void) {
lifecycle.listeners.add(listener)
return { remove: () => lifecycle.listeners.delete(listener) }
}
}
}))
vi.mock('expo-router', async () => {
const React = await import('react')
return {
useFocusEffect(effect: () => void | (() => void)): void {
React.useEffect(() => (lifecycle.focused ? effect() : undefined), [effect, lifecycle.focused])
}
}
})
function result(type?: TestResult['type']): TestResult {
return { snapshotVersion: 1, tabs: ['pending-terminal'], ...(type ? { type } : {}) }
}
function makeHarness() {
const requestTimes: number[] = []
const parkedContexts: (string | null)[] = []
let contextKey: string | null = 'terminal-a'
let otherRecoveryNeeded = false
let connState: 'connected' | 'disconnected' = 'connected'
let streamListener: ((payload: unknown) => void) | null = null
let actions: ReturnType<typeof useMobileSessionTabsReconciliation<TestResult, string>> | null =
null
const sendRequest = vi.fn(async (): Promise<RpcSuccess> => {
requestTimes.push(Date.now())
return {
id: `list-${requestTimes.length}`,
ok: true,
result: result(),
_meta: { runtimeId: 'runtime-1' }
}
})
const subscribe = vi.fn(
(_method: string, _params: unknown, listener: (payload: unknown) => void) => {
streamListener = listener
return () => {}
}
)
const client = { sendRequest, subscribe } as unknown as RpcClient
const consumeAcceptedSessionTabs = () => {}
const fetchTerminals = async () => {}
const getPendingTerminalRecoveryContextKey = () => contextKey
const hasRecoveryNeed = () => otherRecoveryNeeded
const onPendingTerminalRecoveryParked = (key: string | null) => parkedContexts.push(key)
const applySessionTabs = (value: TestResult): SessionTabsApplyOutcome<string> => ({
accepted: true,
effectiveTabs: value.tabs
})
function Harness({ onParked = onPendingTerminalRecoveryParked, suspend }: HarnessProps): null {
actions = useMobileSessionTabsReconciliation<TestResult, string>({
client,
connState,
worktreeId: 'repo::worktree',
applySessionTabs,
consumeAcceptedSessionTabs,
fetchTerminals,
hasRecoveryNeed,
pendingTerminalRecoveryContextKey: contextKey,
getPendingTerminalRecoveryContextKey,
onPendingTerminalRecoveryParked: onParked
})
if (suspend) {
throw suspend
}
return null
}
return {
Harness,
actions: () => actions,
emit(payload: TestResult) {
streamListener?.(payload)
},
parkedContexts,
requestTimes,
sendRequest,
setConnectionState(value: typeof connState) {
connState = value
},
setContextKey(value: string | null) {
contextKey = value
},
setOtherRecoveryNeeded(value: boolean) {
otherRecoveryNeeded = value
}
}
}
async function flush(): Promise<void> {
for (let turn = 0; turn < 8; turn += 1) {
await Promise.resolve()
}
}
// react-test-renderer's types do not resolve in the type-aware lint project, so both
// ReactTestRenderer and ReturnType<typeof create> degrade to `any` and poison this union.
// Name the two methods this suite actually uses instead.
type MountedHarness = {
unmount: () => void
update: (element: ReturnType<typeof createElement>) => void
}
describe('bounded pending-handle reconciliation cadence', () => {
let renderer: MountedHarness | null = null
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(0)
lifecycle.appState = 'active'
lifecycle.focused = true
lifecycle.listeners.clear()
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.useRealTimers()
})
async function mount(harness: ReturnType<typeof makeHarness>): Promise<void> {
await act(async () => {
renderer = create(createElement(harness.Harness))
await flush()
})
await act(async () => {
harness.emit(result('snapshot'))
await flush()
harness.emit(result('updated'))
await flush()
})
harness.sendRequest.mockClear()
harness.requestTimes.length = 0
harness.parkedContexts.length = 0
}
async function advance(milliseconds: number): Promise<void> {
await act(async () => {
await vi.advanceTimersByTimeAsync(milliseconds)
})
}
async function setAppState(state: string): Promise<void> {
lifecycle.appState = state
await act(async () => {
for (const listener of lifecycle.listeners) {
listener(state)
}
await flush()
})
}
it('drops the main baseline from 1800 lists per hour to five, then parks', async () => {
const harness = makeHarness()
await mount(harness)
await advance(3_600_000)
expect(harness.requestTimes).toEqual([2000, 4000, 6000, 8000, 10_000])
expect(harness.parkedContexts).toContain('terminal-a')
})
it('resets for active-tab and pending-terminal context changes, but not ordinary ticks', async () => {
const harness = makeHarness()
await mount(harness)
await advance(12_000)
expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS)
harness.setContextKey(null)
await advance(2000)
const afterActiveTabChange = harness.requestTimes.length
harness.setContextKey('terminal-a')
await advance(2000)
expect(harness.requestTimes).toHaveLength(afterActiveTabChange + 1)
harness.setContextKey('terminal-b')
await advance(2000)
expect(harness.requestTimes).toHaveLength(afterActiveTabChange + 2)
})
it('resets when the active tab changes away and back between poll ticks', async () => {
const harness = makeHarness()
await mount(harness)
await advance(12_000)
harness.setContextKey(null)
await act(async () => {
renderer?.update(createElement(harness.Harness))
await flush()
})
expect(harness.parkedContexts.at(-1)).toBeNull()
harness.setContextKey('terminal-a')
await act(async () => {
renderer?.update(createElement(harness.Harness))
await flush()
})
expect(harness.parkedContexts.at(-1)).toBeNull()
await advance(2000)
expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 1)
})
it('keeps the parked state across unrelated rerenders', async () => {
const harness = makeHarness()
await mount(harness)
await advance(12_000)
expect(harness.parkedContexts.at(-1)).toBe('terminal-a')
await act(async () => {
renderer?.update(createElement(harness.Harness))
await flush()
})
expect(harness.parkedContexts.at(-1)).toBe('terminal-a')
await advance(2000)
expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS)
})
it('does not publish a parked callback from a suspended render', async () => {
const harness = makeHarness()
const committedCallback = vi.fn()
const suspendedCallback = vi.fn()
const neverCommits = new Promise<never>(() => {})
await act(async () => {
renderer = create(
createElement(
Suspense,
{ fallback: null },
createElement(harness.Harness, { onParked: committedCallback })
)
)
await flush()
})
const committedActions = harness.actions()
committedCallback.mockClear()
await act(async () => {
renderer?.update(
createElement(
Suspense,
{ fallback: null },
createElement(harness.Harness, {
onParked: suspendedCallback,
suspend: neverCommits
})
)
)
await flush()
})
await act(async () => {
await committedActions?.retryPendingTerminalRecovery()
})
expect(committedCallback).toHaveBeenCalledWith(null)
expect(suspendedCallback).not.toHaveBeenCalled()
})
it('publishes the committed parked callback before a same-commit context reset', async () => {
const harness = makeHarness()
const previousCallback = vi.fn()
const committedCallback = vi.fn()
await act(async () => {
renderer = create(createElement(harness.Harness, { onParked: previousCallback }))
await flush()
})
previousCallback.mockClear()
harness.setContextKey('terminal-b')
await act(async () => {
renderer?.update(createElement(harness.Harness, { onParked: committedCallback }))
await flush()
})
expect(committedCallback).toHaveBeenCalledWith(null)
expect(previousCallback).not.toHaveBeenCalled()
})
it('keeps other recovery sources live while the pending-terminal budget is parked', async () => {
const harness = makeHarness()
await mount(harness)
await advance(12_000)
expect(harness.requestTimes).toEqual([2000, 4000, 6000, 8000, 10_000])
harness.setOtherRecoveryNeeded(true)
await advance(14_000)
expect(harness.requestTimes).toEqual([
2000, 4000, 6000, 8000, 10_000, 14_000, 16_000, 18_000, 20_000, 22_000, 24_000, 26_000
])
harness.setOtherRecoveryNeeded(false)
await advance(2000)
expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 7)
})
it('resets after foregrounding, reconnecting, and explicit retry', async () => {
const harness = makeHarness()
await mount(harness)
await advance(12_000)
await setAppState('background')
await setAppState('active')
expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 1)
await advance(2000)
expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 2)
await advance(10_000)
harness.setConnectionState('disconnected')
await act(async () => {
renderer?.update(createElement(harness.Harness))
await flush()
})
harness.setConnectionState('connected')
await act(async () => {
renderer?.update(createElement(harness.Harness))
await flush()
})
await act(async () => {
harness.emit(result('updated'))
await flush()
})
const afterReconnect = harness.requestTimes.length
await advance(2000)
expect(harness.requestTimes).toHaveLength(afterReconnect + 1)
await advance(10_000)
const beforeRetry = harness.requestTimes.length
await act(async () => {
await harness.actions()?.retryPendingTerminalRecovery()
})
expect(harness.requestTimes).toHaveLength(beforeRetry + 1)
await advance(2000)
expect(harness.requestTimes).toHaveLength(beforeRetry + 2)
})
})
@@ -2,7 +2,12 @@ import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse } from '../transport/types'
import type { MobileSessionTab, SessionTabsResult } from './mobile-session-route-types'
import { hasPendingTerminalHandleRecoveryNeed } from './pending-terminal-handle-recovery'
import {
getPendingTerminalHandleRecoveryContextKey,
hasPendingTerminalHandleRecoveryNeed,
PendingTerminalHandleRecoveryBudget,
PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS
} from './pending-terminal-handle-recovery'
import {
MobileSessionTabsStreamHealth,
type SessionTabsApplyOutcome
@@ -62,6 +67,38 @@ describe('hasPendingTerminalHandleRecoveryNeed', () => {
expect(hasPendingTerminalHandleRecoveryNeed([terminalTab(null)], null)).toBe(false)
expect(hasPendingTerminalHandleRecoveryNeed([terminalTab(null)], 'gone')).toBe(false)
})
it('keeps recovery contexts distinct when tab identifiers contain separators', () => {
const first = { ...terminalTab(null), id: 'a:b', parentTabId: 'c' }
const second = { ...terminalTab(null), id: 'a', parentTabId: 'b:c' }
expect(getPendingTerminalHandleRecoveryContextKey([first], first.id)).not.toBe(
getPendingTerminalHandleRecoveryContextKey([second], second.id)
)
})
})
describe('PendingTerminalHandleRecoveryBudget', () => {
it('parks after the bounded attempt window and resets for a new terminal', () => {
const budget = new PendingTerminalHandleRecoveryBudget()
for (let attempt = 1; attempt <= PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS; attempt += 1) {
expect(budget.take('terminal-a')).toEqual({
allowed: true,
parked: false
})
}
expect(budget.take('terminal-a')).toEqual({ allowed: false, parked: true })
expect(budget.take('terminal-b')).toEqual({ allowed: true, parked: false })
})
it('resets the current terminal budget explicitly', () => {
const budget = new PendingTerminalHandleRecoveryBudget()
for (let attempt = 0; attempt < PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS; attempt += 1) {
budget.take('terminal-a')
}
budget.reset()
expect(budget.take('terminal-a')).toEqual({ allowed: true, parked: false })
})
})
/**
@@ -82,6 +119,7 @@ describe('pending-handle recovery through MobileSessionTabsStreamHealth', () =>
const client = { sendRequest, getGeneration: () => 1 } as unknown as RpcClient
let tabs: MobileSessionTab[] = []
let activeTabId: string | null = null
const recoveryBudget = new PendingTerminalHandleRecoveryBudget()
const controller = new MobileSessionTabsStreamHealth<SessionTabsResult, MobileSessionTab>({
client,
scope: 'id:repo::worktree',
@@ -91,7 +129,9 @@ describe('pending-handle recovery through MobileSessionTabsStreamHealth', () =>
return { accepted: true, effectiveTabs: result.tabs }
},
consumeAccepted: () => {},
hasRecoveryNeed: () => hasPendingTerminalHandleRecoveryNeed(tabs, activeTabId)
hasRecoveryNeed: () => hasPendingTerminalHandleRecoveryNeed(tabs, activeTabId),
allowRecoveryPoll: () =>
recoveryBudget.take(getPendingTerminalHandleRecoveryContextKey(tabs, activeTabId)).allowed
})
return {
controller,
@@ -139,6 +179,88 @@ describe('pending-handle recovery through MobileSessionTabsStreamHealth', () =>
})
})
it('parks the live poll after five pending-handle lists', async () => {
const harness = makeHarness()
await driveToLiveStream(harness, null)
for (let attempt = 0; attempt < PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS; attempt += 1) {
const request = harness.controller.poll()
expect(request).not.toBeNull()
await settle()
harness.resolveNext(snapshot(null))
await request
}
expect(harness.sendRequest).toHaveBeenCalledTimes(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS)
expect(harness.controller.poll()).toBeNull()
await settle()
expect(harness.sendRequest).toHaveBeenCalledTimes(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS)
})
it('does not spend live fallback attempts while a slow request is coalesced', async () => {
const harness = makeHarness()
await driveToLiveStream(harness, null)
for (let attempt = 0; attempt < PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS; attempt += 1) {
const request = harness.controller.poll()
expect(request).not.toBeNull()
for (let joinedTick = 0; joinedTick < 5; joinedTick += 1) {
expect(harness.controller.poll()).toBe(request)
}
expect(harness.sendRequest).toHaveBeenCalledTimes(attempt + 1)
harness.resolveNext(snapshot(null))
await request
}
expect(harness.controller.poll()).toBeNull()
expect(harness.sendRequest).toHaveBeenCalledTimes(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS)
})
it.each(['probing', 'degraded'] as const)(
'keeps polling a pending handle while stream health is %s',
async (health) => {
const harness = makeHarness()
harness.controller.setReconciliationActive(true)
const subscription = harness.controller.beginSubscription()
if (health === 'degraded') {
subscription.listener({ ...snapshot(null), type: 'end' })
}
for (let attempt = 0; attempt < PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 2; attempt += 1) {
const request = harness.controller.poll()
expect(request).not.toBeNull()
await settle()
harness.resolveNext(snapshot(null))
await request
}
expect(harness.sendRequest).toHaveBeenCalledTimes(
PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 2
)
}
)
it('starts a fresh retry and fences the half-open request result', async () => {
const harness = makeHarness()
await driveToLiveStream(harness, null)
const halfOpen = harness.controller.poll()
expect(harness.sendRequest).toHaveBeenCalledTimes(1)
const retry = harness.controller.retryReconciliation()
expect(retry).not.toBe(halfOpen)
expect(harness.sendRequest).toHaveBeenCalledTimes(2)
expect(harness.controller.retryReconciliation()).toBe(retry)
expect(harness.sendRequest).toHaveBeenCalledTimes(2)
harness.resolveNext(snapshot('stale-handle'))
await halfOpen
expect(harness.readTabs()[0]).toMatchObject({ terminal: null })
harness.resolveNext(snapshot('fresh-handle'))
await retry
expect(harness.readTabs()[0]).toMatchObject({ terminal: 'fresh-handle' })
})
it('stops polling once a fresh snapshot carries the handle', async () => {
const harness = makeHarness()
await driveToLiveStream(harness, null)
@@ -1,5 +1,74 @@
import type { MobileSessionTab } from './mobile-session-route-types'
export const PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS = 5
export type PendingTerminalHandleRecoveryAttempt = {
allowed: boolean
parked: boolean
}
export class PendingTerminalHandleRecoveryBudget {
private contextKey: string | null = null
private remaining = PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS
observeContext(contextKey: string | null): void {
if (contextKey === this.contextKey) {
return
}
this.contextKey = contextKey
this.remaining = PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS
}
take(contextKey: string | null): PendingTerminalHandleRecoveryAttempt {
this.observeContext(contextKey)
if (contextKey === null) {
return { allowed: false, parked: false }
}
if (this.remaining === 0) {
return { allowed: false, parked: true }
}
this.remaining -= 1
return { allowed: true, parked: false }
}
reset(): void {
this.contextKey = null
this.remaining = PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS
}
}
export class PendingTerminalHandleRecoveryContextCache {
private activeTabId: string | null = null
private contextKey: string | null = null
private initialized = false
private tabs: readonly MobileSessionTab[] | null = null
read(tabs: readonly MobileSessionTab[], activeTabId: string | null): string | null {
if (this.initialized && tabs === this.tabs && activeTabId === this.activeTabId) {
return this.contextKey
}
this.initialized = true
this.tabs = tabs
this.activeTabId = activeTabId
this.contextKey = getPendingTerminalHandleRecoveryContextKey(tabs, activeTabId)
return this.contextKey
}
}
export function getPendingTerminalHandleRecoveryContextKey(
tabs: readonly MobileSessionTab[],
activeTabId: string | null
): string | null {
if (activeTabId === null) {
return null
}
const active = tabs.find((tab) => tab.id === activeTabId)
if (active?.type !== 'terminal' || typeof active.terminal === 'string') {
return null
}
return JSON.stringify([active.id, active.parentTabId, active.leafId ?? null])
}
/**
* Whether the tab the user is looking at is a terminal the host published
* without a PTY handle (`status: 'pending-handle'`). The session screen renders
@@ -16,9 +85,5 @@ export function hasPendingTerminalHandleRecoveryNeed(
tabs: readonly MobileSessionTab[],
activeTabId: string | null
): boolean {
if (activeTabId === null) {
return false
}
const active = tabs.find((tab) => tab.id === activeTabId)
return active?.type === 'terminal' && typeof active.terminal !== 'string'
return getPendingTerminalHandleRecoveryContextKey(tabs, activeTabId) !== null
}
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo } from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { AppState } from 'react-native'
import { useFocusEffect } from 'expo-router'
import type { RpcClient } from '../transport/rpc-client'
@@ -8,6 +8,7 @@ import {
type SessionTabsApplyOutcome,
type SessionTabsStreamSource
} from './mobile-session-tabs-stream-health'
import { PendingTerminalHandleRecoveryBudget } from './pending-terminal-handle-recovery'
type Params<Result, Tab> = {
client: RpcClient | null
@@ -21,6 +22,9 @@ type Params<Result, Tab> = {
) => void
fetchTerminals: () => Promise<void>
hasRecoveryNeed: () => boolean
pendingTerminalRecoveryContextKey?: string | null
getPendingTerminalRecoveryContextKey?: () => string | null
onPendingTerminalRecoveryParked?: (contextKey: string | null) => void
getApplicationRevision?: () => number
onFetchStarted?: () => void
onFetchSucceeded?: (result: Result) => void
@@ -32,6 +36,7 @@ type ResultActions = {
fetchSessionTabs: () => Promise<void>
ensureSessionTabs: () => Promise<void>
fetchPendingBrowserSessionTabs: () => Promise<void>
retryPendingTerminalRecovery: () => Promise<void>
}
const resolved = Promise.resolve()
@@ -44,12 +49,46 @@ export function useMobileSessionTabsReconciliation<Result, Tab>({
consumeAcceptedSessionTabs,
fetchTerminals,
hasRecoveryNeed,
pendingTerminalRecoveryContextKey,
getPendingTerminalRecoveryContextKey,
onPendingTerminalRecoveryParked,
getApplicationRevision,
onFetchStarted,
onFetchSucceeded,
onFetchFailed,
onFetchErrored
}: Params<Result, Tab>): ResultActions {
const pendingTerminalRecoveryBudget = useMemo(() => new PendingTerminalHandleRecoveryBudget(), [])
const onPendingTerminalRecoveryParkedRef = useRef(onPendingTerminalRecoveryParked)
// Why: only poll/reset callbacks read this, and they run after commit — so writing it in
// render would let a discarded render leak a callback that never mounted.
useEffect(() => {
onPendingTerminalRecoveryParkedRef.current = onPendingTerminalRecoveryParked
})
const combinedHasRecoveryNeed = useCallback(() => {
const contextKey = getPendingTerminalRecoveryContextKey?.() ?? null
pendingTerminalRecoveryBudget.observeContext(contextKey)
return hasRecoveryNeed() || contextKey !== null
}, [getPendingTerminalRecoveryContextKey, hasRecoveryNeed, pendingTerminalRecoveryBudget])
const allowRecoveryPoll = useCallback(() => {
if (hasRecoveryNeed()) {
return true
}
const contextKey = getPendingTerminalRecoveryContextKey?.() ?? null
const attempt = pendingTerminalRecoveryBudget.take(contextKey)
if (attempt.parked) {
onPendingTerminalRecoveryParkedRef.current?.(contextKey)
}
return attempt.allowed
}, [getPendingTerminalRecoveryContextKey, hasRecoveryNeed, pendingTerminalRecoveryBudget])
const resetPendingTerminalRecovery = useCallback(() => {
pendingTerminalRecoveryBudget.reset()
onPendingTerminalRecoveryParkedRef.current?.(null)
}, [pendingTerminalRecoveryBudget])
useEffect(() => {
pendingTerminalRecoveryBudget.observeContext(pendingTerminalRecoveryContextKey ?? null)
onPendingTerminalRecoveryParkedRef.current?.(null)
}, [pendingTerminalRecoveryBudget, pendingTerminalRecoveryContextKey])
const controller = useMemo(
() =>
client
@@ -58,7 +97,8 @@ export function useMobileSessionTabsReconciliation<Result, Tab>({
scope: `id:${worktreeId}`,
apply: applySessionTabs,
consumeAccepted: consumeAcceptedSessionTabs,
hasRecoveryNeed,
hasRecoveryNeed: combinedHasRecoveryNeed,
allowRecoveryPoll: getPendingTerminalRecoveryContextKey ? allowRecoveryPoll : undefined,
getApplicationRevision,
onFetchStarted,
onFetchSucceeded,
@@ -71,7 +111,9 @@ export function useMobileSessionTabsReconciliation<Result, Tab>({
client,
consumeAcceptedSessionTabs,
getApplicationRevision,
hasRecoveryNeed,
allowRecoveryPoll,
combinedHasRecoveryNeed,
getPendingTerminalRecoveryContextKey,
onFetchErrored,
onFetchFailed,
onFetchStarted,
@@ -91,6 +133,7 @@ export function useMobileSessionTabsReconciliation<Result, Tab>({
if (!client || !controller || connState !== 'connected') {
return
}
resetPendingTerminalRecovery()
const subscription = controller.beginSubscription()
const unsubscribe = client.subscribe(
'session.tabs.subscribe',
@@ -101,7 +144,7 @@ export function useMobileSessionTabsReconciliation<Result, Tab>({
subscription.cancel()
unsubscribe()
}
}, [client, connState, controller, worktreeId])
}, [client, connState, controller, resetPendingTerminalRecovery, worktreeId])
useFocusEffect(
useCallback(() => {
@@ -123,19 +166,21 @@ export function useMobileSessionTabsReconciliation<Result, Tab>({
}
const appStateSubscription = AppState.addEventListener('change', (state) => {
if (state === 'active') {
resetPendingTerminalRecovery()
refresh(true)
} else {
controller.setReconciliationActive(false)
}
})
const interval = setInterval(() => refresh(false), 2000)
resetPendingTerminalRecovery()
refresh(true)
return () => {
controller.setReconciliationActive(false)
clearInterval(interval)
appStateSubscription.remove()
}
}, [connState, controller, fetchTerminals])
}, [connState, controller, fetchTerminals, resetPendingTerminalRecovery])
)
return {
@@ -150,6 +195,10 @@ export function useMobileSessionTabsReconciliation<Result, Tab>({
fetchPendingBrowserSessionTabs: useCallback(
() => controller?.requestPendingRecovery() ?? resolved,
[controller]
)
),
retryPendingTerminalRecovery: useCallback(() => {
resetPendingTerminalRecovery()
return controller?.retryReconciliation() ?? resolved
}, [controller, resetPendingTerminalRecovery])
}
}
+2 -1
View File
@@ -752,7 +752,8 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
attachWebglAddon(true);
if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) try { term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion = '11'; } catch (e) {}
if (typeof replayData === 'string' && replayData.length > 0) {
enqueueWrite(replayData);
// Why no trailing reset: the snapshot pen belongs to the live host TUI receiving later output.
enqueueWrite(ESC + '[0m' + replayData);
}
// Why: reset eviction tracking + attach observers for the new term.
@@ -26,7 +26,7 @@ type RegisteredWindowListener = {
type: string
}
function makeTerminal(writeCallbacks: Array<() => void>) {
function makeTerminal(writeCallbacks: Array<() => void>, writes: string[]) {
const terminal = {
cols: 80,
rows: 24,
@@ -45,7 +45,8 @@ function makeTerminal(writeCallbacks: Array<() => void>) {
getLine: () => null
}
},
write(_data: string, callback?: () => void) {
write(data: string, callback?: () => void) {
writes.push(data)
if (callback) {
writeCallbacks.push(callback)
}
@@ -93,6 +94,7 @@ describe('terminal WebView init surface replacement', () => {
let terminalOptions: TerminalOptions[]
let terminals: TerminalStub[]
let writeCallbacks: Array<() => void>
let writes: string[]
beforeEach(() => {
animationFrames = []
@@ -100,6 +102,7 @@ describe('terminal WebView init surface replacement', () => {
terminalOptions = []
terminals = []
writeCallbacks = []
writes = []
const addWindowEventListener = window.addEventListener.bind(window)
vi.spyOn(window, 'addEventListener').mockImplementation(((
type: string,
@@ -121,7 +124,7 @@ describe('terminal WebView init surface replacement', () => {
}
webWindow.Terminal = function (options: TerminalOptions) {
terminalOptions.push(options)
const terminal = makeTerminal(writeCallbacks)
const terminal = makeTerminal(writeCallbacks, writes)
terminals.push(terminal)
return terminal
} as unknown as new (options: TerminalOptions) => TerminalStub
@@ -153,6 +156,13 @@ describe('terminal WebView init surface replacement', () => {
}
})
it('grounds the initial replay without clearing the host live pen', () => {
dispatchInit(80, '\x1b[1mBOLD-RUN-LEFT-OPEN')
animationFrames.shift()?.()
expect(writes).toEqual(['\x1b[0m\x1b[1mBOLD-RUN-LEFT-OPEN'])
})
it('commits only the newest surface when phone-fit init calls overlap', () => {
// Why: restored terminals can receive desktop scrollback, a phone resize,
// and phone scrollback before any xterm replay callback has completed.
@@ -144,7 +144,7 @@ describe('TerminalWebView text zoom', () => {
expect(terminalHtmlSource).toContain('window.Unicode11Addon.Unicode11Addon')
const open = terminalHtmlSource.indexOf('term.open(surface)')
const unicode = terminalHtmlSource.indexOf("term.unicode.activeVersion = '11'")
const replay = terminalHtmlSource.indexOf('enqueueWrite(replayData)')
const replay = terminalHtmlSource.indexOf("enqueueWrite(ESC + '[0m' + replayData)")
expect(open).toBeGreaterThanOrEqual(0)
expect(unicode).toBeGreaterThan(open)
expect(replay).toBeGreaterThan(unicode)
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { useRpcClientContext, type RpcClientContextValue } from './client-context'
import type { MobileConnectionPath } from './stable-logical-rpc-client'
export function useReconnectAttempt(hostId: string | undefined): number {
return useHostMetric(hostId, (context, id) => context.getReconnectAttempt(id), 0)
@@ -9,6 +10,10 @@ export function useLastConnectedAt(hostId: string | undefined): number | null {
return useHostMetric(hostId, (context, id) => context.getLastConnectedAt(id), null)
}
export function usePendingConnectionPath(hostId: string | undefined): MobileConnectionPath | null {
return useHostMetric(hostId, (context, id) => context.getPendingPath(id), null)
}
function useHostMetric<T>(
hostId: string | undefined,
read: (context: RpcClientContextValue, hostId: string) => T,
@@ -3,6 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ConnectionState } from './types'
import type { RpcClient } from './rpc-client'
import type { MobileConnectionPath } from './stable-logical-rpc-client'
const connectMock = vi.fn()
const loadHostsMock = vi.fn()
@@ -31,12 +32,15 @@ import { selectHomeAutoConnectHostIds } from './home-host-auto-connect'
type FakeClient = RpcClient & {
emitState: (state: ConnectionState) => void
emitPendingPath: (path: MobileConnectionPath | null) => void
closeMock: ReturnType<typeof vi.fn>
}
function makeFakeClient(initialState: ConnectionState): FakeClient {
let state = initialState
let pendingPath: MobileConnectionPath | null = null
const listeners = new Set<(state: ConnectionState) => void>()
const pathListeners = new Set<() => void>()
const closeMock = vi.fn()
return {
sendRequest: vi.fn(),
@@ -45,6 +49,12 @@ function makeFakeClient(initialState: ConnectionState): FakeClient {
getState: () => state,
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
getActivePath: () => 'tailscale',
getPendingPath: () => pendingPath,
onConnectionPathChange: (listener: () => void) => {
pathListeners.add(listener)
return () => pathListeners.delete(listener)
},
onStateChange: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
@@ -57,6 +67,12 @@ function makeFakeClient(initialState: ConnectionState): FakeClient {
for (const listener of listeners) {
listener(next)
}
},
emitPendingPath: (next) => {
pendingPath = next
for (const listener of pathListeners) {
listener()
}
}
} as FakeClient
}
@@ -393,6 +409,30 @@ describe('useHostClient', () => {
})
describe('useAllHostClients', () => {
it('rerenders when Relay becomes pending without a transport-state change', async () => {
const client = makeFakeClient('reconnecting')
connectMock.mockReturnValue(client)
loadHostsMock.mockResolvedValue([HOST])
let pendingPath: MobileConnectionPath | null | undefined
let renderer!: ReturnType<typeof create>
function Probe(): null {
pendingPath = useAllHostClients([HOST.id])[0]?.pendingPath
return null
}
await act(async () => {
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
await Promise.resolve()
})
expect(pendingPath).toBeNull()
act(() => client.emitPendingPath('relay'))
expect(pendingPath).toBe('relay')
act(() => renderer.unmount())
})
it('only opens the requested startup subset', async () => {
const host2 = { ...HOST, id: 'host-2', name: 'Host 2' }
connectMock.mockReturnValue(makeFakeClient('connected'))
+8 -12
View File
@@ -76,6 +76,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
primedHostsRef.current.delete(hostId)
}
entry?.unsubState()
entry?.unsubConnectionPath()
storeRef.current.delete(hostId)
entry?.client.close()
notifyHostState(hostId, 'disconnected')
@@ -242,6 +243,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
manualDemandRef.current.add(hostId)
if (entry) {
entry.unsubState()
entry.unsubConnectionPath()
entry.client.close()
storeRef.current.delete(hostId)
}
@@ -260,8 +262,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
[openEntry]
)
const { getKnownState, getState, getReconnectAttempt, getLastConnectedAt, getActivePath } =
useMemo(() => createHostClientSelectors(storeRef.current, pendingOpensRef.current), [])
const selectors = useMemo(
() => createHostClientSelectors(storeRef.current, pendingOpensRef.current),
[]
)
const subscribeHostState = useCallback(
(hostId: string, listener: (state: ConnectionState) => void) =>
@@ -318,11 +322,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
refreshHostClient,
forgetHostClient,
disconnectHostClient,
getState,
getKnownState,
getReconnectAttempt,
getLastConnectedAt,
getActivePath,
...selectors,
subscribeHostState,
getAllClients,
subscribeAllHosts,
@@ -337,11 +337,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
refreshHostClient,
forgetHostClient,
disconnectHostClient,
getState,
getKnownState,
getReconnectAttempt,
getLastConnectedAt,
getActivePath,
selectors,
subscribeHostState,
getAllClients,
subscribeAllHosts,
+33 -1
View File
@@ -25,7 +25,8 @@ describe('classifyConnection Tailscale hint', () => {
const verdict = classifyConnection({
...base,
reconnectAttempts: 3,
endpoint: 'ws://100.65.9.106:6768'
endpoint: 'ws://100.65.9.106:6768',
pendingPath: null
})
expect(verdict).toMatchObject({ kind: 'warning', hint: 'check Tailscale' })
})
@@ -69,6 +70,37 @@ describe('classifyConnection Tailscale hint', () => {
})
expect(verdict).toEqual({ kind: 'normal', label: 'Connected' })
})
it('presents an in-flight relay fallback instead of the failed Tailscale path', () => {
const incident = {
...base,
reconnectAttempts: 5,
endpoint: 'ws://100.88.90.25:6768',
pendingPath: 'relay' as const
}
for (const state of ['connecting', 'handshaking', 'reconnecting', 'disconnected'] as const) {
const verdict = classifyConnection({ ...incident, state })
expect(verdict).toEqual({ kind: 'normal', label: 'Connecting via Relay…' })
expect(verdictDisplayLabel(verdict)).not.toContain('Tailscale')
}
})
it('escalates a prolonged relay recovery without reviving the Tailscale hint', () => {
const verdict = classifyConnection({
...base,
reconnectAttempts: 12,
endpoint: 'ws://100.88.90.25:6768',
pendingPath: 'relay'
})
expect(verdict).toEqual({
kind: 'unreachable',
label: "Can't connect via Relay",
reason: 'never-connected'
})
expect(verdictDisplayLabel(verdict)).not.toContain('Tailscale')
})
})
// Issue #10119: every redial re-enters 'connecting', which used to revert an
+14
View File
@@ -1,4 +1,5 @@
import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
import type { MobileConnectionPath } from './stable-logical-rpc-client'
import type { ConnectionState } from './types'
// Why: thresholds for escalating connection UX from neutral
@@ -49,6 +50,7 @@ export function classifyConnection(args: {
// Optional pinned host endpoint — enables the Tailscale hint on
// warning/unreachable verdicts. Callers without it get plain labels.
endpoint?: string | null
pendingPath?: MobileConnectionPath | null
nowMs?: number
}): ConnectionVerdict {
const { state, reconnectAttempts, lastConnectedAt } = args
@@ -65,6 +67,18 @@ export function classifyConnection(args: {
return { kind: 'normal', label: 'Connected' }
}
if (args.pendingPath === 'relay') {
if (reconnectAttempts >= UNREACHABLE_ATTEMPTS) {
if (lastConnectedAt == null) {
return { kind: 'unreachable', label: "Can't connect via Relay", reason: 'never-connected' }
}
if (now - lastConnectedAt >= STALE_SINCE_LAST_CONNECT_MS) {
return { kind: 'unreachable', label: "Can't connect via Relay", reason: 'stale' }
}
}
return { kind: 'normal', label: 'Connecting via Relay…' }
}
if (state === 'disconnected') {
return { kind: 'normal', label: 'Disconnected' }
}
@@ -84,7 +84,9 @@ export function createHostClientSelectors(
getLastConnectedAt: (hostId: string): number | null =>
entries.get(hostId)?.client.getLastConnectedAt() ?? null,
getActivePath: (hostId: string): MobileConnectionPath =>
clientActivePath(entries.get(hostId)?.client)
clientActivePath(entries.get(hostId)?.client),
getPendingPath: (hostId: string): MobileConnectionPath | null =>
clientPendingPath(entries.get(hostId)?.client)
}
}
@@ -96,3 +98,8 @@ export function clientActivePath(client: RpcClient | undefined): MobileConnectio
// Why: during migration the pending path is what the user is waiting on.
return logical.getPendingPath?.() ?? logical.getActivePath()
}
export function clientPendingPath(client: RpcClient | undefined): MobileConnectionPath | null {
const logical = client as Partial<StableLogicalRpcClient> | undefined
return logical?.getPendingPath?.() ?? null
}
+12 -1
View File
@@ -4,6 +4,7 @@ import { openHostLogicalClient } from './host-logical-client'
import type { HostClientOpenRegistry } from './host-client-open-registry'
import type { HostOpenRetryScheduler } from './host-open-retry-scheduler'
import type { RpcClient } from './rpc-client'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionState, HostProfile } from './types'
export type HostClientStoreEntry = {
@@ -11,6 +12,7 @@ export type HostClientStoreEntry = {
state: ConnectionState
refCount: number
unsubState: () => void
unsubConnectionPath: () => void
}
type HostEntryOpenerState = {
@@ -113,11 +115,20 @@ export async function openHostClientEntry(
current.state = next
state.notifyHostState(hostId, next)
})
const logical = client as Partial<StableLogicalRpcClient>
const unsubConnectionPath =
logical.onConnectionPathChange?.(() => {
const current = state.store.get(hostId)
if (current) {
state.notifyHostState(hostId, current.state)
}
}) ?? (() => {})
const entry: HostClientStoreEntry = {
client,
state: client.getState(),
refCount: state.pendingAcquisitions.get(hostId) ?? 0,
unsubState
unsubState,
unsubConnectionPath
}
state.pendingAcquisitions.delete(hostId)
state.store.set(hostId, entry)
@@ -0,0 +1,46 @@
import type { MobileConnectionPath } from './stable-logical-rpc-client'
export class LogicalClientConnectionPath {
private migration: MobileConnectionPath | null = null
private recovery: MobileConnectionPath | null = null
private readonly listeners = new Set<() => void>()
constructor(private readonly isConnected: () => boolean) {}
pending(): MobileConnectionPath | null {
return this.isConnected() ? null : (this.migration ?? this.recovery)
}
setMigration(path: MobileConnectionPath | null): void {
this.update(() => {
this.migration = path
})
}
clearAfterConnected(): void {
this.migration = null
this.recovery = null
}
setRecovery(path: MobileConnectionPath | null): void {
this.update(() => {
this.recovery = path
})
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
private update(apply: () => void): void {
const previous = this.pending()
apply()
if (previous === this.pending()) {
return
}
for (const listener of this.listeners) {
listener()
}
}
}
@@ -65,7 +65,9 @@ export class FakeRelaySession extends FakeSession implements MobileRelayRpcSessi
export class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
private path: MobileConnectionPath
private recoveryPath: MobileConnectionPath | null = null
private generation = 1
private readonly pathListeners = new Set<() => void>()
constructor(state: ConnectionState, path: MobileConnectionPath) {
super(state)
@@ -89,14 +91,28 @@ export class FakeLogicalClient extends FakeSession implements StableLogicalRpcCl
throw new Error('migration superseded')
}
this.path = path
this.recoveryPath = null
this.generation += 1
// Connected-state publication carries the migration cleanup.
this.publishState('connected')
}
)
suspendActiveSession = vi.fn(() => this.publishState('disconnected'))
getActivePath = () => this.path
// This fake migrates instantly, so no dial is ever in flight to name.
getPendingPath = () => null
getPendingPath = () => (this.getState() === 'connected' ? null : this.recoveryPath)
setRecoveryPath = vi.fn((path: MobileConnectionPath | null) => {
const previous = this.getPendingPath()
this.recoveryPath = path
if (previous !== this.getPendingPath()) {
for (const listener of this.pathListeners) {
listener()
}
}
})
onConnectionPathChange = vi.fn((listener: () => void) => {
this.pathListeners.add(listener)
return () => this.pathListeners.delete(listener)
})
getGeneration = () => this.generation
}
@@ -91,6 +91,63 @@ describe('mobile endpoint supervisor', () => {
supervisor.stop()
})
it('does not announce Relay when credential selection finishes after backgrounding', async () => {
const logical = new FakeLogicalClient('reconnecting', 'lan')
let finishCredentialRead: ((value: MobileRelayCredentialBundle) => void) | undefined
const credentialReadPending = new Promise<MobileRelayCredentialBundle>((resolve) => {
finishCredentialRead = resolve
})
const readBundle = vi
.fn()
.mockResolvedValueOnce(null)
.mockReturnValueOnce(credentialReadPending)
const deps = dependencies({ readBundle })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
const starting = supervisor.start()
await vi.waitFor(() => expect(readBundle).toHaveBeenCalledTimes(2))
supervisor.setForeground(false)
finishCredentialRead?.(bundle)
await starting
expect(deps.openRelay).not.toHaveBeenCalled()
expect(logical.setRecoveryPath).not.toHaveBeenCalledWith('relay')
expect(logical.getPendingPath()).toBeNull()
supervisor.stop()
})
it('does not announce Relay without a dialable credential', async () => {
const logical = new FakeLogicalClient('reconnecting', 'lan')
const expired = { ...bundle, current: { ...bundle.current, expiresAt: Date.now() - 1 } }
const deps = dependencies({ readBundle: vi.fn(async () => expired) })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
expect(deps.openRelay).not.toHaveBeenCalled()
expect(logical.setRecoveryPath).not.toHaveBeenCalledWith('relay')
expect(logical.getPendingPath()).toBeNull()
supervisor.stop()
})
it('keeps Relay pending through a failed dial cooldown and clears it on stop', async () => {
const logical = new FakeLogicalClient('reconnecting', 'lan')
const deps = dependencies({
openRelay: vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4408))),
randomBytes: () => new Uint8Array([128, 0])
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
expect(logical.getPendingPath()).toBe('relay')
await vi.advanceTimersByTimeAsync(249)
expect(logical.getPendingPath()).toBe('relay')
supervisor.stop()
expect(logical.getPendingPath()).toBeNull()
})
it('does not spend a queued relay retry while direct authentication is progressing', async () => {
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4408)))
@@ -187,6 +244,7 @@ describe('mobile endpoint supervisor', () => {
logical.publishState('disconnected')
expect(openRelay).toHaveBeenCalledOnce()
expect(logical.getPendingPath()).toBe('relay')
await vi.advanceTimersByTimeAsync(249)
expect(openRelay).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1)
@@ -194,6 +252,22 @@ describe('mobile endpoint supervisor', () => {
supervisor.stop()
})
it('does not announce recovery when an active Relay drops after credential expiry', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const expired = { ...bundle, current: { ...bundle.current, expiresAt: Date.now() - 1 } }
const deps = dependencies({ readBundle: vi.fn(async () => expired) })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
logical.publishState('disconnected')
await vi.advanceTimersByTimeAsync(0)
expect(deps.openRelay).not.toHaveBeenCalled()
expect(logical.setRecoveryPath).not.toHaveBeenCalledWith('relay')
expect(logical.getPendingPath()).toBeNull()
supervisor.stop()
})
it('escalates backoff when relay sessions connect and then drop repeatedly', async () => {
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi.fn(() => new FakeRelaySession('connected', new RelayOuterError(4408)))
@@ -432,6 +506,7 @@ describe('mobile endpoint supervisor', () => {
expect(openRelay).toHaveBeenCalledOnce()
expect(deps.writeBundle).not.toHaveBeenCalled()
expect(logical.getPendingPath()).toBeNull()
logical.publishState('connected')
await vi.waitFor(() => expect(deps.writeBundle).toHaveBeenCalledOnce())
@@ -17,6 +17,7 @@ import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bund
import { MobileEndpointNudgeRouter } from './mobile-endpoint-nudge-router'
import { MobileRelayDirectGraceTimer } from './mobile-relay-direct-grace-timer'
import { MobileRelaySessionEstablisher } from './mobile-relay-session-establisher'
import * as recoveryPresentation from './mobile-relay-recovery-presentation'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ForegroundNudgeReason, HostProfile } from './types'
@@ -154,6 +155,7 @@ export class MobileEndpointSupervisor {
} else {
// Why: the direct client enters reconnecting after its first failed
// dial and may never publish disconnected while its retry loop lives.
recoveryPresentation.onActiveFailure(this.logical, this.relayReconnect, state, this.bundle)
this.relayReconnect.handleStateFailure(this.logical, state)
}
})
@@ -181,12 +183,11 @@ export class MobileEndpointSupervisor {
this.relayReconnect.clear()
this.leaseRotation.clear()
this.directGrace.clear()
this.logical.setRecoveryPath(null)
}
}
nudge(reason: ForegroundNudgeReason): void {
this.nudgeRouter.nudge(reason)
}
nudge = (reason: ForegroundNudgeReason): void => this.nudgeRouter.nudge(reason)
stop(): void {
this.stopped = true
@@ -196,6 +197,7 @@ export class MobileEndpointSupervisor {
this.relayReconnect.clear()
this.leaseRotation.clear()
this.directGrace.clear()
this.logical.setRecoveryPath(null)
}
// forceReplacement: dial past the "direct still looks live" guard — a lease
@@ -235,7 +237,6 @@ export class MobileEndpointSupervisor {
return
}
this.operationInFlight = true
let lastError: Error | null = null
let retryAfterOperation = false
try {
const selection = await selectDialableRelayCredentials({
@@ -246,6 +247,7 @@ export class MobileEndpointSupervisor {
})
this.bundle = selection.bundle
if (selection.credentials.length === 0) {
this.logical.setRecoveryPath(null)
// Why: "expired" vs "missing" separates a sleep-past-expiry phone
// (needs re-pair or LAN) from a Keychain failure in field reports.
this.logRelay(
@@ -261,6 +263,12 @@ export class MobileEndpointSupervisor {
}
return
}
const recoveryNeeded =
forceReplacement || this.relayReconnect.needsRecovery(this.logical.getState())
if (this.stopped || !this.foreground || !recoveryNeeded) {
return
}
this.logical.setRecoveryPath('relay')
const dialed = await this.sessionEstablisher.dialEligible(selection.credentials)
if (dialed.outcome === 'established') {
// Why: a fresh socket satisfies any replacement intent queued mid-dial.
@@ -269,15 +277,16 @@ export class MobileEndpointSupervisor {
return
}
if (dialed.outcome === 'aborted') {
this.logical.setRecoveryPath(null)
// Why: direct won the race or the supervisor went inactive — not a
// failure; booking backoff would delay the next genuine recovery.
return
}
lastError = dialed.error
// Why: cleanup may happen while a relay dial is awaiting the network;
// record its outcome without recreating a foreground retry timer.
const scheduleRetry = (!forceReplacement || ownsRecovery) && this.foreground && !this.stopped
this.relayReconnect.registerFailure(lastError, scheduleRetry)
this.relayReconnect.registerFailure(dialed.error, scheduleRetry)
recoveryPresentation.clearIfCredentialBlocked(this.logical, this.relayReconnect)
if (ownsRecovery) {
suspendRelayIfStillConnected(this.relayReconnect, this.logical)
}
@@ -7,9 +7,12 @@ import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel'
import { RelayOuterError } from './mobile-relay-e2ee-link'
import { RELAY_STABLE_CONNECTION_MS, RelayRetryDelays } from './mobile-relay-retry-delays'
import { RelayCredentialEligibility } from './relay-credential-eligibility'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionState, ForegroundNudgeReason } from './types'
type RelayCredentialLease = { expiresAt: number; version: number }
export type RelayReconnectDependencies = {
now: () => number
randomBytes: (length: number) => Uint8Array
@@ -28,7 +31,7 @@ export class RelayReconnectController {
private recoveryGate: RecoveryGate | null = null
private gateReprobePending = false
private gateReprobeStreak = 0
private readonly rejectedCredentialVersions = new Set<number>()
private readonly credentials: RelayCredentialEligibility
private readonly delays: RelayRetryDelays
constructor(
@@ -36,6 +39,7 @@ export class RelayReconnectController {
private readonly onRetry: (forceReplacement?: boolean) => void
) {
this.delays = new RelayRetryDelays(dependencies.randomBytes)
this.credentials = new RelayCredentialEligibility(dependencies.now)
}
handleForeground(logical: StableLogicalRpcClient, wasForeground: boolean): void {
@@ -105,7 +109,7 @@ export class RelayReconnectController {
resetForDirectConnection(): boolean {
const needsCredentialRefresh =
this.recoveryGate === 'fresh-credential' || this.rejectedCredentialVersions.size > 0
this.recoveryGate === 'fresh-credential' || this.credentials.hasRejected()
this.activeSession = null
this.activeRelayConnectedAt = null
if (needsCredentialRefresh) {
@@ -126,22 +130,22 @@ export class RelayReconnectController {
completeCredentialRefresh(): void {
if (this.recoveryGate === 'fresh-credential') {
this.rejectedCredentialVersions.clear()
this.credentials.clearRejected()
this.reset()
}
}
eligibleCredentials<T extends { expiresAt: number; version: number }>(
blocksUntilFreshCredential = (): boolean => this.recoveryGate === 'fresh-credential'
hasDialableCredential(...credentials: (RelayCredentialLease | null | undefined)[]): boolean {
return this.credentials.hasDialable(...credentials)
}
eligibleCredentials<T extends RelayCredentialLease>(
...credentials: Array<T | null | undefined>
): T[] {
const eligible = credentials.filter((credential): credential is T =>
Boolean(
credential &&
credential.expiresAt > this.dependencies.now() &&
!this.rejectedCredentialVersions.has(credential.version)
)
)
if (eligible.length === 0 && this.rejectedCredentialVersions.size > 0) {
const eligible = this.credentials.eligible(...credentials)
if (eligible.length === 0 && this.credentials.hasRejected()) {
this.recoveryGate = 'fresh-credential'
this.clearTimer()
this.scheduleGateReprobe()
@@ -152,7 +156,7 @@ export class RelayReconnectController {
// For callers that found no dialable credential at all: keep a slow retry
// alive so a later durable write can recover.
armCredentialReprobe(): void {
if (this.rejectedCredentialVersions.size > 0) {
if (this.credentials.hasRejected()) {
this.recoveryGate = 'fresh-credential'
this.clearTimer()
this.scheduleGateReprobe()
@@ -176,14 +180,12 @@ export class RelayReconnectController {
// A durable bundle whose current version is not rejected reopens the gate.
acceptFreshCredential(version: number): void {
if (this.recoveryGate === 'fresh-credential' && !this.rejectedCredentialVersions.has(version)) {
if (this.recoveryGate === 'fresh-credential' && !this.credentials.isRejected(version)) {
this.liftGate()
}
}
recordRejectedCredential(version: number): void {
this.rejectedCredentialVersions.add(version)
}
recordRejectedCredential = (version: number): void => this.credentials.recordRejected(version)
registerActiveFailure(logical: StableLogicalRpcClient): void {
if (logical.getActivePath() !== 'relay') {
@@ -0,0 +1,29 @@
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import type { RelayReconnectController } from './mobile-relay-reconnect-controller'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionState } from './types'
export function onActiveFailure(
logical: StableLogicalRpcClient,
controller: RelayReconnectController,
state: ConnectionState,
bundle: MobileRelayCredentialBundle | null
): void {
if (
controller.needsRecovery(state) &&
logical.getActivePath() === 'relay' &&
bundle &&
controller.hasDialableCredential(bundle.current, bundle.grace)
) {
logical.setRecoveryPath('relay')
}
}
export function clearIfCredentialBlocked(
logical: StableLogicalRpcClient,
controller: RelayReconnectController
): void {
if (controller.blocksUntilFreshCredential()) {
logical.setRecoveryPath(null)
}
}
@@ -90,6 +90,9 @@ class FakeRelaySession extends FakeSession implements MobileRelayRpcSession {
class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
private path: MobileConnectionPath
private recoveryPath: MobileConnectionPath | null = null
private generation = 1
private readonly pathListeners = new Set<() => void>()
constructor(state: ConnectionState, path: MobileConnectionPath) {
super(state)
@@ -102,10 +105,28 @@ class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
throw new Error(`replacement session ${session.getState()}`)
}
this.path = path
this.recoveryPath = null
this.generation += 1
// Connected-state publication carries the migration cleanup.
this.publishState('connected')
})
suspendActiveSession = vi.fn(() => this.publishState('disconnected'))
getActivePath = () => this.path
getPendingPath = () => (this.getState() === 'connected' ? null : this.recoveryPath)
setRecoveryPath = vi.fn((path: MobileConnectionPath | null) => {
const previous = this.getPendingPath()
this.recoveryPath = path
if (previous !== this.getPendingPath()) {
for (const listener of this.pathListeners) {
listener()
}
}
})
onConnectionPathChange = vi.fn((listener: () => void) => {
this.pathListeners.add(listener)
return () => this.pathListeners.delete(listener)
})
getGeneration = () => this.generation
}
const relay = {
@@ -237,12 +258,15 @@ describe('relay runtime recovery without direct connectivity', () => {
await supervisor.start()
await vi.advanceTimersByTimeAsync(0)
expect(deps.openRelay).not.toHaveBeenCalled()
expect(logical.setRecoveryPath).not.toHaveBeenCalledWith('relay')
expect(logical.getPendingPath()).toBeNull()
readBundle.mockImplementation(async () => bundleWith(3, Number.MAX_SAFE_INTEGER))
// Pre-fix, an expired bundle produced a silent no-op with nothing scheduled.
await vi.advanceTimersByTimeAsync(60_000)
expect(deps.openRelay).toHaveBeenCalledOnce()
expect(logical.setRecoveryPath).toHaveBeenCalledWith('relay')
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
@@ -0,0 +1,41 @@
type RelayCredentialLease = { expiresAt: number; version: number }
export class RelayCredentialEligibility {
private readonly rejectedVersions = new Set<number>()
constructor(private readonly now: () => number) {}
hasRejected(): boolean {
return this.rejectedVersions.size > 0
}
clearRejected(): void {
this.rejectedVersions.clear()
}
isRejected(version: number): boolean {
return this.rejectedVersions.has(version)
}
recordRejected(version: number): void {
this.rejectedVersions.add(version)
}
hasDialable(...credentials: (RelayCredentialLease | null | undefined)[]): boolean {
return credentials.some((credential) => this.isDialable(credential))
}
eligible<T extends RelayCredentialLease>(...credentials: (T | null | undefined)[]): T[] {
return credentials.filter((credential): credential is T => this.isDialable(credential))
}
private isDialable<T extends RelayCredentialLease>(
credential: T | null | undefined
): credential is T {
return Boolean(
credential &&
credential.expiresAt > this.now() &&
!this.rejectedVersions.has(credential.version)
)
}
}
@@ -21,6 +21,7 @@ export type RpcClientContextValue = {
getReconnectAttempt: (hostId: string) => number
getLastConnectedAt: (hostId: string) => number | null
getActivePath: (hostId: string) => MobileConnectionPath
getPendingPath: (hostId: string) => MobileConnectionPath | null
subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void
getAllClients: () => { hostId: string; client: RpcClient }[]
subscribeAllHosts: (listener: () => void) => () => void
@@ -4,7 +4,8 @@ import type { RpcClient } from './rpc-client'
import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
import {
createStableLogicalRpcClient,
LogicalClientCutoverError
LogicalClientCutoverError,
type MobileConnectionPath
} from './stable-logical-rpc-client'
class FakeSession implements RpcClient {
@@ -259,10 +260,13 @@ describe('stable logical RPC client', () => {
const client = createStableLogicalRpcClient(direct, 'lan')
direct.setState('reconnecting')
const states: ConnectionState[] = []
const paths: (MobileConnectionPath | null)[] = []
client.onStateChange((next) => states.push(next))
client.onConnectionPathChange(() => paths.push(client.getPendingPath()))
const migrating = client.migrateTo(replacement, 'relay')
expect(client.getPendingPath()).toBe('relay')
expect(paths).toEqual(['relay'])
replacement.setState('connecting')
replacement.setState('handshaking')
@@ -278,6 +282,37 @@ describe('stable logical RPC client', () => {
expect(client.getActivePath()).toBe('relay')
})
it('publishes recovery-path changes and keeps Relay pending between failed dials', async () => {
const direct = new FakeSession('reconnecting')
const replacement = new FakeSession('connecting')
const client = createStableLogicalRpcClient(direct, 'tailscale')
const paths: (MobileConnectionPath | null)[] = []
client.onConnectionPathChange(() => paths.push(client.getPendingPath()))
client.setRecoveryPath('relay')
const migrating = client.migrateTo(replacement, 'relay')
replacement.setState('disconnected')
await expect(migrating).rejects.toThrow(/disconnected/)
expect(client.getPendingPath()).toBe('relay')
expect(paths).toEqual(['relay'])
client.setRecoveryPath(null)
expect(client.getPendingPath()).toBeNull()
expect(paths).toEqual(['relay', null])
})
it('does not revive a stale recovery path after a connection later drops', () => {
const direct = new FakeSession('reconnecting')
const client = createStableLogicalRpcClient(direct, 'tailscale')
client.setRecoveryPath('relay')
direct.setState('connected')
direct.setState('reconnecting')
expect(client.getPendingPath()).toBeNull()
})
it('drops the pending path when the previous session recovers mid-dial', async () => {
const direct = new FakeSession('connected')
const replacement = new FakeSession('connecting')
@@ -6,6 +6,7 @@ import {
} from './migration-dial-state-forwarder'
import { waitForAuthenticated } from './replacement-session-authentication'
import { projectMobileRpcRequestParams } from './mobile-rpc-request-projection'
import { LogicalClientConnectionPath } from './logical-client-connection-path'
export type MobileConnectionPath = 'lan' | 'tailscale' | 'relay'
@@ -47,9 +48,10 @@ export type StableLogicalRpcClient = RpcClient & {
): Promise<void>
suspendActiveSession(): void
getActivePath(): MobileConnectionPath
// Non-null only while a migration dial is publishing its own phases — the path the
// user is waiting on, which the still-bound active path can't name.
// The path the user is waiting on while migration or scheduled recovery is active.
getPendingPath(): MobileConnectionPath | null
setRecoveryPath(path: MobileConnectionPath | null): void
onConnectionPathChange(listener: () => void): () => void
getGeneration(): number
}
@@ -59,7 +61,6 @@ export function createStableLogicalRpcClient(
): StableLogicalRpcClient {
let activeSession = initialSession
let activePath = initialPath
let pendingPath: MobileConnectionPath | null = null
let generation = 1
let closed = false
let suspended = false
@@ -69,6 +70,7 @@ export function createStableLogicalRpcClient(
const pendingRequests = new Set<PendingRequest>()
const stateListeners = new Set<(state: ConnectionState) => void>()
let state = initialSession.getState()
const connectionPath = new LogicalClientConnectionPath(() => state === 'connected')
bindActiveState(initialSession, generation)
@@ -207,7 +209,7 @@ export function createStableLogicalRpcClient(
// (direct dial fails) sits in 'reconnecting' — already amber, so forwarding adds
// nothing, but the user still has no idea relay is what's being tried.
if (suspended || state !== 'connected') {
pendingPath = path
connectionPath.setMigration(path)
}
const forwarder = forwardMigrationDialState({
session: nextSession,
@@ -259,6 +261,7 @@ export function createStableLogicalRpcClient(
}
pendingRequests.clear()
state = nextSession.getState()
connectionPath.clearAfterConnected()
for (const listener of stateListeners) {
listener(state)
}
@@ -268,7 +271,9 @@ export function createStableLogicalRpcClient(
getActivePath: () => activePath,
// Why: a previous session that recovers mid-dial makes the pending path a lie —
// once we're connected the user is no longer waiting on anything.
getPendingPath: () => (state === 'connected' ? null : pendingPath),
getPendingPath: () => connectionPath.pending(),
setRecoveryPath: (path) => connectionPath.setRecovery(path),
onConnectionPathChange: (listener) => connectionPath.subscribe(listener),
getGeneration: () => generation
}
@@ -276,7 +281,9 @@ export function createStableLogicalRpcClient(
function endDialForwarding(forwarder: MigrationDialStateForwarder, failed: boolean): void {
forwarder.stop()
pendingPath = null
if (failed) {
connectionPath.setMigration(null)
}
// Why: only walk back phases we published ourselves — a 'connected' here came from
// the still-live previous session and outranks the dead dial.
if (failed && forwarder.forwarded() && state !== 'connected') {
@@ -314,6 +321,9 @@ export function createStableLogicalRpcClient(
return
}
state = next
if (next === 'connected') {
connectionPath.clearAfterConnected()
}
for (const listener of stateListeners) {
listener(next)
}
+10 -1
View File
@@ -136,10 +136,19 @@ export function useAllHostClients(hostIds: string[], options?: UseAllHostClients
client: RpcClient
state: ConnectionState
path: MobileConnectionPath
pendingPath: MobileConnectionPath | null
}>((hostId) => {
const client = clientsByHostId.get(hostId)
return client
? [{ hostId, client, state: ctx.getState(hostId), path: ctx.getActivePath(hostId) }]
? [
{
hostId,
client,
state: ctx.getState(hostId),
path: ctx.getActivePath(hostId),
pendingPath: ctx.getPendingPath(hostId)
}
]
: []
})
}, [ctx, hostIds, tick])
@@ -1,6 +1,6 @@
import type { Worktree } from './workspace-list-sections'
// Why: matches desktop's PR_GROUP_META naming from worktree-list-groups.ts.
// Why: matches desktop's PR_GROUP_META naming from worktree-list/rows/groups.ts.
// no PR/draft/unknown -> "In Progress", open -> "In Review", merged -> "Done", closed -> "Closed"
export type PRGroupKey = 'done' | 'in-review' | 'in-progress' | 'closed'
@@ -727,7 +727,7 @@ final class Provider {
private func click(params: [String: JSONValue]) throws -> [String: Any] {
let snapshot = try currentSnapshot(params: params)
let button = params["mouseButton"]?.string ?? "left"
let button = try mouseButton(params["mouseButton"]?.string)
let count = try positiveInteger(params["clickCount"]?.number, defaultValue: 1, name: "clickCount")
guard count <= SyntheticMouseClickDelivery.maxClickCount else {
throw ProviderError.coded(
@@ -741,13 +741,16 @@ final class Provider {
recoverWindow(snapshot.app, windowId: snapshot.windowId, windowBounds: snapshot.windowBounds)
if let elementIndex = try optionalInteger(params, "elementIndex") {
let record = try element(snapshot, elementIndex)
if modifiers.isEmpty, count <= 1, let actionName = try performClickAction(record: record, mouseButton: button) {
if modifiers.isEmpty,
count <= 1,
button.hasAccessibilityAction,
let actionName = try performClickAction(record: record, mouseButton: button) {
return actionMetadata(path: "accessibility", actionName: actionName)
}
if let point = center(record.localFrame, in: snapshot.windowBounds) {
try Input.click(
at: point,
button: mouseButton(button),
button: button,
count: count,
modifiers: modifiers,
targetWindow: snapshot
@@ -763,7 +766,7 @@ final class Provider {
let point = try coordinatePoint(params: params, xKey: "x", yKey: "y", snapshot: snapshot)
try Input.click(
at: point,
button: mouseButton(button),
button: button,
count: count,
modifiers: modifiers,
targetWindow: snapshot
@@ -774,8 +777,8 @@ final class Provider {
)
}
private func performClickAction(record: ElementRecord, mouseButton: String) throws -> String? {
if mouseButton == "right" {
private func performClickAction(record: ElementRecord, mouseButton: MouseButtonSelection) throws -> String? {
if mouseButton == .right {
return performAction(record.element, "AXShowMenu") ? "AXShowMenu" : nil
}
for action in ["AXPress", "AXConfirm", "AXOpen"] {
@@ -1634,16 +1637,17 @@ private func screenshotScale(screenshot: ScreenshotPayload?, bounds: CGRect) ->
)
}
private enum MouseButton {
case left
case right
extension MouseButtonSelection {
// Why: macOS has no dedicated middle-button event family; it rides `otherMouse*`
// with the button number carried by `mouseButton:` on the event constructor.
var cgButton: CGMouseButton {
switch self {
case .left:
return .left
case .right:
return .right
case .middle:
return .center
}
}
@@ -1653,6 +1657,8 @@ private enum MouseButton {
return .leftMouseDown
case .right:
return .rightMouseDown
case .middle:
return .otherMouseDown
}
}
@@ -1662,20 +1668,18 @@ private enum MouseButton {
return .leftMouseUp
case .right:
return .rightMouseUp
case .middle:
return .otherMouseUp
}
}
}
private func mouseButton(_ raw: String?) throws -> MouseButton {
switch raw ?? "left" {
case "left":
return .left
case "right":
return .right
case "middle":
throw ProviderError.coded("invalid_argument", "middle-click is not yet supported")
case let value:
throw ProviderError.coded("invalid_argument", "unsupported mouse button '\(value)'")
private func mouseButton(_ raw: String?) throws -> MouseButtonSelection {
switch ActionArgumentValidation.mouseButton(raw) {
case let .success(button):
return button
case let .failure(error):
throw ProviderError.coded("invalid_argument", error.message)
}
}
@@ -2492,7 +2496,7 @@ private func resizePng(_ image: CGImage, scale: CGFloat) -> BoundedPNG? {
private enum Input {
static func click(
at point: CGPoint,
button: MouseButton,
button: MouseButtonSelection,
count: Int,
modifiers: [KeyModifier],
targetWindow: Snapshot
@@ -6,6 +6,18 @@ public struct ActionArgumentValidationError: Error, Equatable {
}
}
public enum MouseButtonSelection: String, Equatable, Sendable, CaseIterable {
case left
case right
case middle
/// Why: AXPress/AXShowMenu only model primary and secondary intent, so a middle
/// click has no accessibility equivalent and must reach the app as real events.
public var hasAccessibilityAction: Bool {
self != .middle
}
}
public enum ActionArgumentValidation {
public static func positiveInteger(
_ value: Double?,
@@ -31,6 +43,16 @@ public enum ActionArgumentValidation {
return .success(value)
}
public static func mouseButton(
_ value: String?
) -> Result<MouseButtonSelection, ActionArgumentValidationError> {
guard let value else { return .success(.left) }
guard let button = MouseButtonSelection(rawValue: value) else {
return .failure(ActionArgumentValidationError("unsupported mouse button '\(value)'"))
}
return .success(button)
}
public static func scrollDirection(_ value: String) -> Result<String, ActionArgumentValidationError> {
switch value {
case "up", "down", "left", "right":
@@ -54,6 +54,30 @@ final class ActionArgumentValidationTests: XCTestCase {
)
}
func testMouseButtonDefaultsToLeftAndAcceptsEveryButton() {
XCTAssertEqual(try ActionArgumentValidation.mouseButton(nil).get(), .left)
XCTAssertEqual(try ActionArgumentValidation.mouseButton("left").get(), .left)
XCTAssertEqual(try ActionArgumentValidation.mouseButton("right").get(), .right)
XCTAssertEqual(try ActionArgumentValidation.mouseButton("middle").get(), .middle)
}
func testMouseButtonRejectsUnknownButtons() {
XCTAssertEqual(
failureMessage(ActionArgumentValidation.mouseButton("primary")),
"unsupported mouse button 'primary'"
)
XCTAssertEqual(
failureMessage(ActionArgumentValidation.mouseButton("")),
"unsupported mouse button ''"
)
}
func testOnlyMiddleButtonLacksAnAccessibilityAction() {
XCTAssertTrue(MouseButtonSelection.left.hasAccessibilityAction)
XCTAssertTrue(MouseButtonSelection.right.hasAccessibilityAction)
XCTAssertFalse(MouseButtonSelection.middle.hasAccessibilityAction)
}
func testScrollDirectionRejectsUnknownDirections() {
XCTAssertEqual(try ActionArgumentValidation.scrollDirection("down").get(), "down")
XCTAssertEqual(
+110
View File
@@ -0,0 +1,110 @@
import Carbon
import Foundation
struct KeyCharacters: Codable {
let unmodified: String?
let shifted: String?
}
struct KeyboardLayoutSnapshot: Codable {
let inputSourceId: String?
let layoutSourceId: String?
let keyCharacters: [String: KeyCharacters]
}
let domKeyCodes: [(String, UInt16)] = [
("KeyA", 0), ("KeyS", 1), ("KeyD", 2), ("KeyF", 3), ("KeyH", 4), ("KeyG", 5),
("KeyZ", 6), ("KeyX", 7), ("KeyC", 8), ("KeyV", 9), ("KeyB", 11), ("KeyQ", 12),
("KeyW", 13), ("KeyE", 14), ("KeyR", 15), ("KeyY", 16), ("KeyT", 17),
("Digit1", 18), ("Digit2", 19), ("Digit3", 20), ("Digit4", 21), ("Digit6", 22),
("Digit5", 23), ("Equal", 24), ("Digit9", 25), ("Digit7", 26), ("Minus", 27),
("Digit8", 28), ("Digit0", 29), ("BracketRight", 30), ("KeyO", 31), ("KeyU", 32),
("BracketLeft", 33), ("KeyI", 34), ("KeyP", 35), ("KeyL", 37), ("KeyJ", 38),
("Quote", 39), ("KeyK", 40), ("Semicolon", 41), ("Backslash", 42), ("Comma", 43),
("Slash", 44), ("KeyN", 45), ("KeyM", 46), ("Period", 47), ("Space", 49),
("Backquote", 50), ("IntlBackslash", 10), ("IntlYen", 93), ("IntlRo", 94)
]
func inputSourceId(_ source: TISInputSource?) -> String? {
guard let source,
let rawId = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) else {
return nil
}
return Unmanaged<CFString>.fromOpaque(rawId).takeUnretainedValue() as String
}
func translatedCharacter(
layout: UnsafePointer<UCKeyboardLayout>,
keyCode: UInt16,
modifiers: UInt32
) -> String? {
var deadKeyState: UInt32 = 0
var length = 0
var characters = [UniChar](repeating: 0, count: 8)
let status = UCKeyTranslate(
layout,
keyCode,
UInt16(kUCKeyActionDown),
modifiers,
UInt32(LMGetKbdType()),
OptionBits(kUCKeyTranslateNoDeadKeysMask),
&deadKeyState,
characters.count,
&length,
&characters
)
guard status == noErr, length > 0 else {
return nil
}
return String(utf16CodeUnits: characters, count: length)
}
func readStableSnapshot() -> KeyboardLayoutSnapshot? {
let currentInputSource = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue()
let currentLayoutSource = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue()
let capturedInputSourceId = inputSourceId(currentInputSource)
let capturedLayoutSourceId = inputSourceId(currentLayoutSource)
var keyCharacters: [String: KeyCharacters] = [:]
if let currentLayoutSource,
let rawData = TISGetInputSourceProperty(
currentLayoutSource,
kTISPropertyUnicodeKeyLayoutData
) {
let data = Unmanaged<CFData>.fromOpaque(rawData).takeUnretainedValue()
if let bytes = CFDataGetBytePtr(data) {
let layout = UnsafeRawPointer(bytes).assumingMemoryBound(to: UCKeyboardLayout.self)
for (code, keyCode) in domKeyCodes {
keyCharacters[code] = KeyCharacters(
unmodified: translatedCharacter(layout: layout, keyCode: keyCode, modifiers: 0),
shifted: translatedCharacter(
layout: layout,
keyCode: keyCode,
modifiers: UInt32(shiftKey >> 8)
)
)
}
}
}
let finalInputSource = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue()
let finalLayoutSource = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue()
guard capturedInputSourceId == inputSourceId(finalInputSource),
capturedLayoutSourceId == inputSourceId(finalLayoutSource) else {
return nil
}
return KeyboardLayoutSnapshot(
inputSourceId: capturedInputSourceId,
layoutSourceId: capturedLayoutSourceId,
keyCharacters: keyCharacters
)
}
for _ in 0..<2 {
if let snapshot = readStableSnapshot() {
let encoded = try JSONEncoder().encode(snapshot)
print(String(decoding: encoded, as: UTF8.self))
exit(0)
}
}
exit(1)
+4 -2
View File
@@ -25,6 +25,7 @@
"lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs",
"prepare": "husky",
"test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts",
"test:skill-sharing:release": "vitest run --config config/vitest.config.ts src/main/skills src/main/runtime/rpc/methods/skills.test.ts src/relay/skill-install-handler.test.ts src/shared/skill-bundle-install-contract.test.ts src/shared/skill-install-contract.test.ts src/shared/skill-install-failure.test.ts src/shared/skill-package-manifest.test.ts",
"test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs",
"check:reliability-gates": "node config/scripts/check-reliability-gates.mjs",
"check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
@@ -54,6 +55,7 @@
"dev:web": "vite --config vite.web.config.ts --host 127.0.0.1",
"build:relay": "node config/scripts/build-relay.mjs",
"build:computer-macos": "node config/scripts/build-computer-macos.mjs",
"build:keyboard-layout-macos": "node config/scripts/build-keyboard-layout-macos.mjs",
"build:notification-status-macos": "node config/scripts/build-notification-status-macos.mjs",
"build:native": "node config/scripts/build-native-for-platform.mjs",
"smoke:computer": "node config/scripts/computer-use-smoke.mjs",
@@ -80,8 +82,8 @@
"build:unpack": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --dir",
"build:win": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --win",
"build:icons": "bash resources/icon-source/generate.sh",
"build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && node config/scripts/build-mac-local.mjs",
"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 && ORCA_MAC_RELEASE=1 pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac",
"build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run build:keyboard-layout-macos && pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && node config/scripts/build-mac-local.mjs",
"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 && ORCA_MAC_RELEASE=1 pnpm run build:keyboard-layout-macos && ORCA_MAC_RELEASE=1 pnpm run build:notification-status-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:workspace-session-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-quit-relaunch-session.spec.ts tests/e2e/golden-terminal-file-link.spec.ts tests/e2e/golden-worktree-create-switch.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
+24 -24
View File
@@ -18,7 +18,7 @@ patchedDependencies:
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
'@xterm/xterm@6.1.0-beta.287':
hash: 8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67
hash: 08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
node-pty@1.1.0:
hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8
@@ -45,7 +45,7 @@ importers:
version: 2.5.6
'@xterm/addon-serialize':
specifier: 0.15.0-beta.287
version: 0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
version: 0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
'@xterm/headless':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287
@@ -208,25 +208,25 @@ importers:
version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
'@xterm/addon-fit':
specifier: 0.12.0-beta.287
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
'@xterm/addon-ligatures':
specifier: 0.11.0-beta.287
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
'@xterm/addon-search':
specifier: 0.17.0-beta.287
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.287
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
'@xterm/addon-web-links':
specifier: 0.13.0-beta.287
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
'@xterm/addon-webgl':
specifier: 0.20.0-beta.286
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
'@xterm/xterm':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
version: 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -9591,39 +9591,39 @@ snapshots:
'@xmldom/xmldom@0.8.13': {}
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
lru-cache: 11.5.1
opentype.js: 2.0.0
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
'@xterm/headless@6.1.0-beta.287': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)': {}
abbrev@4.0.0: {}
+7 -7
View File
@@ -40,18 +40,18 @@
{
"name": "orca-cli",
"sourcePath": "skills/orca-cli",
"releaseRevision": 36,
"packageDigest": "6eaa0624f45402646d87d9a559f2b3dbbc0efd1d5e56cdfde219671ad62d320e",
"gitTreeSha": "a5a90c9731fbee834eca6d655b39eaa795dd2323",
"releaseRevision": 37,
"packageDigest": "d5648df9c29b479bbbe0dea68f0506b2b0bad0827ce551451fb8f300c113b305",
"gitTreeSha": "ee3a35c76f875ea6ebaf68947af13ba6346b13f2",
"files": [
{
"path": "SKILL.md",
"size": 3913,
"size": 3944,
"executable": false,
"classification": "text",
"exactSha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012",
"textNormalizedSha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012",
"identitySha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012"
"exactSha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163",
"textNormalizedSha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163",
"identitySha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163"
}
]
},
+16
View File
@@ -577,6 +577,22 @@
"identitySha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012"
}
]
},
{
"releaseRevision": 37,
"packageDigest": "d5648df9c29b479bbbe0dea68f0506b2b0bad0827ce551451fb8f300c113b305",
"gitTreeSha": "ee3a35c76f875ea6ebaf68947af13ba6346b13f2",
"files": [
{
"path": "SKILL.md",
"size": 3944,
"executable": false,
"classification": "text",
"exactSha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163",
"textNormalizedSha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163",
"identitySha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163"
}
]
}
],
"orchestration": [
+2
View File
@@ -70,6 +70,8 @@ ORCA computer get-app-state --app <app> --restore-window --json
ORCA computer click --app <app> --element-index <index> --json
ORCA computer click --app <app> --x 100 --y 100 --json
ORCA computer click --app <app> --x 100 --y 100 --modifiers CmdOrCtrl+Shift --json
ORCA computer click --app <app> --element-index <index> --mouse-button right --json
ORCA computer click --app <app> --element-index <index> --mouse-button middle --json
ORCA computer perform-secondary-action --app <app> --element-index <index> --action <name> --json
ORCA computer set-value --app <app> --element-index <index> --value "text" --json
ORCA computer type-text --app <app> --text "text" --json
+36 -3
View File
@@ -2,12 +2,12 @@
name: orca-cli
description: >-
Use the public `orca` CLI to operate Orca-managed worktrees, folder contexts,
terminals, repos, automations, artifacts, worktree comments, and the browser
terminals, repos, automations, artifacts, skill sharing, worktree comments, and the browser
embedded inside the Orca app. Use when the user says "$orca-cli", "use orca cli",
"Orca worktree", "child worktree", "cardStatus", "spawn codex/claude in a worktree",
"read/wait/send Orca terminal", "terminal send", "full handoff", "handover",
"give this to another agent", "another worktree", "Orca browser", "orca artifacts",
"share HTML/Markdown", "public artifact link", or "control the browser inside
"share HTML/Markdown", "public artifact link", "share skills", 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 Computer Use for browser windows, webviews, or desktop UI outside Orca's
@@ -272,6 +272,39 @@ ORCA artifacts delete <id> --json
- `ORCA_CLOUD_AUTH_TOKEN` is a development-only authentication override. Prefer the active
Orca profile's normal PropelAuth session and never expose the token in logs or agent output.
## Skill Sharing
Agents can publish one or more installed skills behind one unlisted link through the
signed-in Orca account. The user must first grant the separate, default-off permission in
Settings → Share Skills ("Allow agents and the Orca CLI to publish skill links"). There is
no CLI or RPC way to grant it. Manual publishing from the reviewed desktop flow remains
available without this agent permission.
```text
ORCA skills installed --json
ORCA skills share --skill <selector> [--skill <selector> ...] --bundle-name <name> --json
```
- `skills installed` returns safe discovery IDs and names. It does not expose local skill
paths in CLI output. Sharing then verifies that each `SKILL.md` declares a portable
lowercase name containing only letters, numbers, and hyphens.
- Each `--skill` must be an exact discovery ID or an unambiguous installed-skill name.
Use IDs when names collide.
- Multiple `--skill` flags create one bundle and one link. `--all` and arbitrary paths are
intentionally unsupported; name every skill the user asked to publish.
- Skill folders can contain scripts, configuration, credentials, or other private files.
Treat the permission as authority, not blanket intent: publish only the explicitly
requested skills and never widen the selection.
- A denied command fails with `agent_skill_sharing_disabled`. Do not retry; ask the user to
enable the switch in the desktop app if they want this action.
- Orca stages one agent-published bundle at a time per host. If another publish is active,
wait for it to finish before retrying `agent_skill_sharing_busy`.
- Run the command in an Orca terminal on the machine that stores the skills. Forwarded WSL,
SSH, and paired-runtime invocations fail before discovery so Orca cannot read from the
wrong filesystem.
- The JSON result contains the unlisted URL and public share/package/version IDs. It never
includes cloud authentication tokens.
## Built-In Browser
The built-in browser is Orca's embedded browser tab surface, scoped to Orca worktrees; it is not Chrome/Safari or desktop app UI.
@@ -343,7 +376,7 @@ Common recoveries:
## Next Action
Confirm `orca status --json` unless already checked this turn, then choose the narrowest command for the job: `worktree ps/current/create`, `terminal list/read/wait/send`, `automations list`, `artifacts list/share`, or built-in browser `snapshot`.
Confirm `orca status --json` unless already checked this turn, then choose the narrowest command for the job: `worktree ps/current/create`, `terminal list/read/wait/send`, `automations list`, `artifacts list/share`, `skills installed/share`, or built-in browser `snapshot`.
## Mobile Emulator (iOS Simulator via serve-sim)
+2 -2
View File
@@ -2,12 +2,12 @@
name: orca-cli
description: >-
Use the public `orca` CLI to operate Orca-managed worktrees, folder contexts,
terminals, repos, automations, artifacts, worktree comments, and the browser
terminals, repos, automations, artifacts, skill sharing, worktree comments, and the browser
embedded inside the Orca app. Use when the user says "$orca-cli", "use orca cli",
"Orca worktree", "child worktree", "cardStatus", "spawn codex/claude in a worktree",
"read/wait/send Orca terminal", "terminal send", "full handoff", "handover",
"give this to another agent", "another worktree", "Orca browser", "orca artifacts",
"share HTML/Markdown", "public artifact link", or "control the browser inside
"share HTML/Markdown", "public artifact link", "share skills", 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 Computer Use for browser windows, webviews, or desktop UI outside Orca's
File diff suppressed because one or more lines are too long
+5
View File
@@ -235,6 +235,11 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
keys: ['vm recipe doctor'],
load: async () => (await import('./handlers/vm.js')).VM_HANDLERS
},
{
name: 'skill-sharing',
keys: ['skills installed', 'skills share'],
load: async () => (await import('./handlers/skill-sharing.js')).SKILL_SHARING_HANDLERS
},
{
name: 'skills',
keys: ['skills list', 'skills get', 'skills install', 'skills update'],
@@ -80,6 +80,32 @@ describe('orca computer action CLI routing', () => {
})
})
it('forwards a middle click to the runtime instead of silently downgrading it', async () => {
queueFixtures(callMock, okFixture('req_click', sampleSnapshot()))
await main(
[
'computer',
'click',
'--session',
'manual',
'--app',
'Finder',
'--element-index',
'3',
'--mouse-button',
'middle',
'--json'
],
'/tmp/repo/src'
)
expect(callMock).toHaveBeenCalledWith(
'computer.click',
expect.objectContaining({ mouseButton: 'middle' })
)
})
it('prints session and window context in action follow-up commands', async () => {
queueFixtures(callMock, okFixture('req_click', sampleSnapshot()))
+267
View File
@@ -0,0 +1,267 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { REPEATED_FLAG_SEPARATOR } from '../args'
import { RuntimeRpcFailureError } from '../runtime-client'
import { SKILL_SHARING_HANDLERS } from './skill-sharing'
const successMeta = { runtimeId: 'runtime-1' }
function context(
call: ReturnType<typeof vi.fn>,
flags = new Map<string, string | boolean>(),
options: { isRemote?: boolean; json?: boolean } = {}
) {
return {
client: { call, isRemote: options.isRemote ?? false },
cwd: '/repo',
flags,
json: options.json ?? false
} as never
}
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
})
describe('skill sharing CLI handlers', () => {
it('lists safe installed-skill selectors without local paths', async () => {
const call = vi.fn().mockResolvedValue({
id: 'request-1',
ok: true,
result: {
skills: [
{
id: 'skill-id',
name: 'alpha',
description: 'Alpha skill',
providers: ['codex'],
sourceKind: 'home',
sourceLabel: 'Codex',
rootPath: '/secret/root',
directoryPath: '/secret/root/alpha',
skillFilePath: '/secret/root/alpha/SKILL.md',
installed: true,
updatedAt: null
}
],
sources: [],
scannedAt: 1
},
_meta: successMeta
})
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
await SKILL_SHARING_HANDLERS['skills installed']!(context(call, new Map(), { json: true }))
expect(call).toHaveBeenCalledWith('skills.discover', { cwd: '/repo' })
const output = String(log.mock.calls[0][0])
expect(output).toContain('skill-id')
expect(output).not.toContain('/secret/root')
})
it('denies publishing at preflight before invoking skills.share', async () => {
const call = vi.fn().mockResolvedValue({
id: 'request-1',
ok: true,
result: { settings: { agentSkillSharingEnabled: false } },
_meta: successMeta
})
await expect(
SKILL_SHARING_HANDLERS['skills share']!(
context(
call,
new Map([
['skill', 'alpha'],
['bundle-name', 'team-skills']
])
)
)
).rejects.toMatchObject({ code: 'agent_skill_sharing_disabled' })
expect(call).toHaveBeenCalledExactlyOnceWith('settings.get')
})
it('publishes multiple explicit skills and prints only public output', async () => {
const call = vi
.fn()
.mockResolvedValueOnce({
id: 'settings',
ok: true,
result: { settings: { agentSkillSharingEnabled: true } },
_meta: successMeta
})
.mockResolvedValueOnce({
id: 'share',
ok: true,
result: {
status: 'ok',
value: {
share: { id: 'shr_public', url: 'https://share.onorca.dev/skills/share/shr_public' },
version: {
packageId: 'pkg_public',
versionId: 'ver_public',
name: 'team-skills'
},
selectedSkills: [
{ id: 'alpha-id', name: 'alpha', description: null },
{ id: 'beta-id', name: 'beta', description: null }
]
}
},
_meta: successMeta
})
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
await SKILL_SHARING_HANDLERS['skills share']!(
context(
call,
new Map([
['skill', `alpha${REPEATED_FLAG_SEPARATOR}beta`],
['bundle-name', 'team-skills'],
['release-notes', 'Initial bundle']
]),
{ json: true }
)
)
expect(call).toHaveBeenLastCalledWith(
'skills.share',
{
skillSelectors: ['alpha', 'beta'],
bundleName: 'team-skills',
releaseNotes: 'Initial bundle',
target: { cwd: '/repo' }
},
{ timeoutMs: 600_000 }
)
const output = String(log.mock.calls[0][0])
expect(output).toContain('shr_public')
expect(output).toContain('pkg_public')
expect(output).not.toMatch(/token|secret/i)
})
it('normalizes a human-readable bundle name before publishing', async () => {
const call = vi
.fn()
.mockResolvedValueOnce({
id: 'settings',
ok: true,
result: { settings: { agentSkillSharingEnabled: true } },
_meta: successMeta
})
.mockResolvedValueOnce({
id: 'share',
ok: true,
result: {
status: 'ok',
value: {
share: { id: 'shr_public', url: 'https://share.onorca.dev/skills/share/shr_public' },
version: {
packageId: 'pkg_public',
versionId: 'ver_public',
name: 'team-skills-v2.0'
},
selectedSkills: [{ id: 'alpha-id', name: 'alpha', description: null }]
}
},
_meta: successMeta
})
vi.spyOn(console, 'log').mockImplementation(() => undefined)
await SKILL_SHARING_HANDLERS['skills share']!(
context(
call,
new Map([
['skill', 'alpha'],
['bundle-name', 'Téam Skills -- v2.0']
])
)
)
expect(call).toHaveBeenLastCalledWith(
'skills.share',
{
skillSelectors: ['alpha'],
bundleName: 'team-skills-v2.0',
releaseNotes: '',
target: { cwd: '/repo' }
},
{ timeoutMs: 600_000 }
)
})
it('rejects a bundle name that cannot produce a valid package name', async () => {
const call = vi.fn()
await expect(
SKILL_SHARING_HANDLERS['skills share']!(
context(
call,
new Map([
['skill', 'alpha'],
['bundle-name', '🔥']
])
)
)
).rejects.toMatchObject({
code: 'invalid_argument',
message: '--bundle-name must contain at least one English letter or number.'
})
expect(call).not.toHaveBeenCalled()
})
it.each([
['a forwarded shell', false, '/remote/repo'],
['a paired runtime', true, undefined]
])('rejects %s before any discovery or publish call', async (_label, isRemote, forwardedCwd) => {
if (forwardedCwd) {
vi.stubEnv('ORCA_CLI_CWD', forwardedCwd)
}
const call = vi.fn()
await expect(
SKILL_SHARING_HANDLERS['skills share']!(
context(
call,
new Map([
['skill', 'alpha'],
['bundle-name', 'alpha']
]),
{ isRemote }
)
)
).rejects.toMatchObject({ code: 'invalid_environment' })
expect(call).not.toHaveBeenCalled()
})
it('gives an actionable upgrade error for an older runtime', async () => {
const call = vi
.fn()
.mockResolvedValueOnce({
id: 'settings',
ok: true,
result: { settings: {} },
_meta: successMeta
})
.mockRejectedValueOnce(
new RuntimeRpcFailureError({
id: 'share',
ok: false,
error: { code: 'method_not_found', message: 'Unknown method' },
_meta: successMeta
})
)
await expect(
SKILL_SHARING_HANDLERS['skills share']!(
context(
call,
new Map([
['skill', 'alpha'],
['bundle-name', 'alpha']
])
)
)
).rejects.toMatchObject({ code: 'update_required' })
})
})
+185
View File
@@ -0,0 +1,185 @@
import type {
AgentSkillShareOperation,
AgentSkillShareResult
} from '../../shared/agent-skill-sharing-contract'
import {
AGENT_SKILL_SHARING_DISABLED_CODE,
AGENT_SKILL_SHARING_DISABLED_MESSAGE,
AGENT_SKILL_SHARING_DISABLED_NEXT_STEPS
} from '../../shared/agent-skill-sharing-gate'
import { normalizeSkillBundleName } from '../../shared/skill-bundle-name'
import type { SkillCloudOperation } from '../../shared/skill-cloud-contract'
import type { SkillDiscoveryResult } from '../../shared/skills'
import type { CommandHandler, HandlerContext } from '../dispatch'
import { getRepeatedStringFlag } from '../flags'
import { printResult } from '../format'
import {
RuntimeClientError,
RuntimeRpcFailureError,
type RuntimeRpcSuccess
} from '../runtime-client'
const SHARE_TIMEOUT_MS = 10 * 60_000
type InstalledSkillSummary = Pick<
SkillDiscoveryResult['skills'][number],
'id' | 'name' | 'description' | 'providers' | 'sourceKind' | 'sourceLabel'
>
type SharedSkillSummary = {
url: string
shareId: string
packageId: string
versionId: string
bundleName: string
skills: AgentSkillShareResult['selectedSkills']
}
function stringFlag(ctx: HandlerContext, name: string): string | undefined {
const value = ctx.flags.get(name)
return typeof value === 'string' && value.trim() ? value.trim() : undefined
}
function rejectForwardedSkillFilesystem(ctx: HandlerContext, command: string): void {
if (!process.env.ORCA_CLI_CWD && !ctx.client.isRemote) {
return
}
throw new RuntimeClientError(
'invalid_environment',
`orca skills ${command} must run on the machine whose installed skills you want to use. Run the command from an Orca terminal on that machine.`
)
}
async function preflightPublishCapability(ctx: HandlerContext): Promise<void> {
let enabled: unknown
try {
const response = await ctx.client.call<{
settings?: { agentSkillSharingEnabled?: boolean }
}>('settings.get')
enabled = response.result?.settings?.agentSkillSharingEnabled
} catch {
return
}
if (enabled === false) {
throw new RuntimeClientError(
AGENT_SKILL_SHARING_DISABLED_CODE,
AGENT_SKILL_SHARING_DISABLED_MESSAGE,
{ nextSteps: [...AGENT_SKILL_SHARING_DISABLED_NEXT_STEPS] }
)
}
}
function requireCloudOperation<T>(operation: SkillCloudOperation<T>): T {
if (operation.status === 'ok') {
return operation.value
}
if (operation.status === 'reconnect-required') {
throw new RuntimeClientError('authentication_required', 'Sign in to Orca and try again.')
}
throw new RuntimeClientError('authentication_unconfigured', operation.message)
}
function installedSummary(result: SkillDiscoveryResult): InstalledSkillSummary[] {
return result.skills.map(({ id, name, description, providers, sourceKind, sourceLabel }) => ({
id,
name,
description,
providers,
sourceKind,
sourceLabel
}))
}
function formatInstalledSkills(skills: InstalledSkillSummary[]): string {
if (skills.length === 0) {
return 'No installed skills found.'
}
return skills
.map(
(skill) =>
`${skill.name} (${skill.id})\n ${skill.description ?? 'No description'}\n ${skill.sourceLabel}`
)
.join('\n')
}
function sharedSummary(result: AgentSkillShareResult): SharedSkillSummary {
return {
url: result.share.url,
shareId: result.share.id,
packageId: result.version.packageId,
versionId: result.version.versionId,
bundleName: result.version.name,
skills: result.selectedSkills
}
}
function formatSharedSkill(result: SharedSkillSummary): string {
return `Shared ${result.skills.length} skill${result.skills.length === 1 ? '' : 's'}: ${result.url}`
}
async function callShare(
ctx: HandlerContext,
params: {
skillSelectors: string[]
bundleName: string
releaseNotes: string
target: { cwd: string }
}
): Promise<RuntimeRpcSuccess<AgentSkillShareOperation>> {
try {
return await ctx.client.call<AgentSkillShareOperation>('skills.share', params, {
timeoutMs: SHARE_TIMEOUT_MS
})
} catch (error) {
if (error instanceof RuntimeRpcFailureError && error.code === 'method_not_found') {
throw new RuntimeClientError(
'update_required',
'The connected Orca runtime does not support agent skill sharing yet. Update Orca on that machine and try again.'
)
}
throw error
}
}
export const SKILL_SHARING_HANDLERS: Record<string, CommandHandler> = {
'skills installed': async (ctx) => {
rejectForwardedSkillFilesystem(ctx, 'installed')
const response = await ctx.client.call<SkillDiscoveryResult>('skills.discover', {
cwd: ctx.cwd
})
const skills = installedSummary(response.result)
printResult({ ...response, result: { skills } }, ctx.json, (value) =>
formatInstalledSkills(value.skills)
)
},
'skills share': async (ctx) => {
rejectForwardedSkillFilesystem(ctx, 'share')
const skillSelectors = getRepeatedStringFlag(ctx.flags, 'skill')
if (skillSelectors.length === 0) {
throw new RuntimeClientError(
'invalid_argument',
'Select at least one installed skill with --skill. Run `orca skills installed` to list them.'
)
}
const bundleLabel = stringFlag(ctx, 'bundle-name')
if (!bundleLabel) {
throw new RuntimeClientError('invalid_argument', 'Missing required --bundle-name.')
}
const bundleName = normalizeSkillBundleName(bundleLabel)
if (!bundleName) {
throw new RuntimeClientError(
'invalid_argument',
'--bundle-name must contain at least one English letter or number.'
)
}
await preflightPublishCapability(ctx)
const response = await callShare(ctx, {
skillSelectors,
bundleName,
releaseNotes: stringFlag(ctx, 'release-notes') ?? '',
target: { cwd: ctx.cwd }
})
const value = sharedSummary(requireCloudOperation(response.result))
printResult({ ...response, result: value }, ctx.json, formatSharedSkill)
}
}
+74
View File
@@ -61,3 +61,77 @@ describe('terminal close CLI', () => {
expect(help).toContain('durable persistence')
})
})
describe('terminal send CLI', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('marks combined text and Enter as an agent prompt candidate', async () => {
const call = vi.fn().mockResolvedValue({
result: { send: { handle: 'term-1', accepted: true, bytesWritten: 7 } }
})
vi.spyOn(console, 'log').mockImplementation(() => {})
await TERMINAL_HANDLERS['terminal send']({
flags: new Map<string, string | true>([
['terminal', 'term-1'],
['text', 'review'],
['enter', true]
]),
client: { call } as unknown as RuntimeClient,
cwd: '/tmp/worktree',
json: true
})
expect(call).toHaveBeenCalledWith('terminal.send', {
terminal: 'term-1',
text: 'review',
enter: true,
interrupt: false,
agentPrompt: true,
client: { id: 'orca-cli', type: 'desktop' }
})
})
it('keeps text-only and bare Enter sends as direct terminal input', async () => {
const call = vi.fn().mockResolvedValue({
result: { send: { handle: 'term-1', accepted: true, bytesWritten: 1 } }
})
vi.spyOn(console, 'log').mockImplementation(() => {})
await TERMINAL_HANDLERS['terminal send']({
flags: new Map<string, string | true>([
['terminal', 'term-1'],
['text', 'x']
]),
client: { call } as unknown as RuntimeClient,
cwd: '/tmp/worktree',
json: true
})
await TERMINAL_HANDLERS['terminal send']({
flags: new Map<string, string | true>([
['terminal', 'term-1'],
['enter', true]
]),
client: { call } as unknown as RuntimeClient,
cwd: '/tmp/worktree',
json: true
})
expect(call).toHaveBeenNthCalledWith(1, 'terminal.send', {
terminal: 'term-1',
text: 'x',
enter: false,
interrupt: false,
client: { id: 'orca-cli', type: 'desktop' }
})
expect(call).toHaveBeenNthCalledWith(2, 'terminal.send', {
terminal: 'term-1',
text: undefined,
enter: true,
interrupt: false,
client: { id: 'orca-cli', type: 'desktop' }
})
})
})
+7 -3
View File
@@ -84,11 +84,15 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
printResult(result, json, formatTerminalRead)
},
'terminal send': async ({ flags, client, cwd, json }) => {
const text = getOptionalStringFlag(flags, 'text')
const enter = flags.get('enter') === true
const interrupt = flags.get('interrupt') === true
const result = await client.call<{ send: RuntimeTerminalSend }>('terminal.send', {
terminal: await getTerminalHandle(flags, cwd, client),
text: getOptionalStringFlag(flags, 'text'),
enter: flags.get('enter') === true,
interrupt: flags.get('interrupt') === true,
text,
enter,
interrupt,
...(text && enter && !interrupt ? { agentPrompt: true } : {}),
client: { id: 'orca-cli', type: 'desktop' }
})
printResult(result, json, formatTerminalSend)
+2
View File
@@ -23,6 +23,8 @@ Accounts:
account list List managed Claude and Codex accounts on this Orca host
Skills:
skills installed List installed skill selectors
skills share Publish selected skills behind one unlisted link
skills list List version-matched skill guides bundled with this Orca CLI
skills get Print a version-matched skill guide as Markdown
skills install Install bundled Orca skills globally via the community skills CLI
@@ -0,0 +1,136 @@
import { describe, expect, it, vi } from 'vitest'
const {
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
return createChildProcessModuleMock(spawnMock)
})
import { main } from './index'
import { okFixture, queueFixtures } from './test-fixtures'
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
describe('orca cli worktree awareness', () => {
useWorktreeAwarenessEnvironment({
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
})
it('passes positional automation ids to edit, remove, run, and show', async () => {
queueFixtures(
callMock,
okFixture('req_edit', { automation: { id: 'auto-1', name: 'Paused' } }),
okFixture('req_remove', { removed: true, id: 'auto-1' }),
okFixture('req_run', {
run: {
id: 'run-1',
automationId: 'auto-1',
title: 'Paused run 1',
status: 'pending',
trigger: 'manual',
scheduledFor: 1,
workspaceId: null,
sessionKind: 'terminal',
chatSessionId: null,
terminalSessionId: null,
outputSnapshot: null,
usage: null,
error: null,
startedAt: null,
dispatchedAt: null,
createdAt: 1
}
}),
okFixture('req_show', { automation: { id: 'auto-1', name: 'Paused' } })
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['automations', 'edit', 'auto-1', '--disabled', '--json'], '/tmp/repo')
await main(['automations', 'remove', 'auto-1', '--json'], '/tmp/repo')
await main(['automations', 'run', 'auto-1', '--json'], '/tmp/repo')
await main(['automations', 'show', 'auto-1', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenNthCalledWith(1, 'automation.update', {
id: 'auto-1',
updates: {
name: undefined,
prompt: undefined,
agentId: undefined,
repo: undefined,
workspace: undefined,
workspaceMode: undefined,
baseBranch: undefined,
timezone: undefined,
enabled: false,
missedRunGraceMinutes: undefined
}
})
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.delete', {
id: 'auto-1'
})
expect(callMock).toHaveBeenNthCalledWith(3, 'automation.runNow', {
id: 'auto-1'
})
expect(callMock).toHaveBeenNthCalledWith(4, 'automation.show', {
id: 'auto-1'
})
})
it('rejects ambiguous positional and flag automation ids before dispatch', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(['automations', 'show', 'auto-1', '--id', 'auto-2', '--json'], '/tmp/repo')
expect(callMock).not.toHaveBeenCalled()
expect(logSpy).toHaveBeenCalledTimes(1)
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({
ok: false,
error: {
code: 'invalid_argument',
message: 'Pass --id either positionally or as a flag, not both.'
}
})
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
})
+186
View File
@@ -0,0 +1,186 @@
import { describe, expect, it, vi } from 'vitest'
const {
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
return createChildProcessModuleMock(spawnMock)
})
import { main } from './index'
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
describe('orca cli worktree awareness', () => {
useWorktreeAwarenessEnvironment({
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
})
it('rejects invalid automation --day values before calling the runtime', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'automations',
'create',
'--name',
'Weekly review',
'--trigger',
'weekly',
'--day',
'7',
'--prompt',
'Review open changes',
'--provider',
'codex',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'--day must be an integer from 0 to 6'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it.each([
{
name: 'day on daily preset',
args: ['--trigger', 'daily', '--day', '2'],
message: '--day can only be used with the weekly automation preset'
},
{
name: 'time on custom cron',
args: ['--trigger', '0 9 * * *', '--time', '10:30'],
message: '--time can only be used with preset automation triggers'
},
{
name: 'time on hourly preset',
args: ['--trigger', 'hourly', '--time', '10:30'],
message: '--time cannot be used with the hourly automation preset'
}
])('rejects automation schedule modifier mismatch: $name', async ({ args, message }) => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'automations',
'create',
'--name',
'Daily review',
...args,
'--prompt',
'Review open changes',
'--provider',
'codex',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(message)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it.each([
{
name: 'create',
args: [
'automations',
'create',
'--name',
'Daily review',
'--trigger',
'daily',
'--time',
'--prompt',
'Review open changes',
'--provider',
'codex',
'--json'
]
},
{
name: 'edit',
args: ['automations', 'edit', 'auto-1', '--trigger', 'daily', '--time', '--json']
}
])('rejects bare automation --time on $name', async ({ args }) => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(args, '/tmp/repo')
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'--time must use HH:MM format'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects automation edit schedule modifiers without a schedule flag', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(['automations', 'edit', 'auto-1', '--day', '7', '--json'], '/tmp/repo')
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'--day requires --trigger or --schedule'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
})
@@ -0,0 +1,127 @@
import { describe, expect, it, vi } from 'vitest'
const {
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
return createChildProcessModuleMock(spawnMock)
})
import { main } from './index'
import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures'
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
describe('orca cli worktree awareness', () => {
useWorktreeAwarenessEnvironment({
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
})
it('passes automation session reuse flags through create and edit', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]),
okFixture('req_create', {
automation: { id: 'auto-1', name: 'Daily review' }
}),
okFixture('req_edit', {
automation: { id: 'auto-1', name: 'Daily review' }
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'automations',
'create',
'--name',
'Daily review',
'--trigger',
'daily',
'--prompt',
'Review open changes',
'--provider',
'codex',
'--workspace',
'current',
'--reuse-session',
'--json'
],
'/tmp/repo/feature/src'
)
await main(['automations', 'edit', 'auto-1', '--fresh-session', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', {
limit: 10_000
})
expect(callMock).toHaveBeenNthCalledWith(
2,
'automation.create',
expect.objectContaining({
workspace: 'id:repo-1::/tmp/repo/feature',
workspaceMode: 'existing',
reuseSession: true
})
)
expect(callMock).toHaveBeenNthCalledWith(3, 'automation.update', {
id: 'auto-1',
updates: expect.objectContaining({ reuseSession: false })
})
})
it('rejects conflicting automation session reuse flags', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
['automations', 'edit', 'auto-1', '--reuse-session', '--fresh-session', '--json'],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Use either --reuse-session or --fresh-session, not both.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
})
@@ -0,0 +1,164 @@
import { describe, expect, it, vi } from 'vitest'
const {
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
return createChildProcessModuleMock(spawnMock)
})
import { main } from './index'
import { okFixture, queueFixtures } from './test-fixtures'
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
describe('orca cli worktree awareness', () => {
useWorktreeAwarenessEnvironment({
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
})
it('passes automation source context JSON through create', async () => {
const sourceContext = {
kind: 'task-source',
provider: 'github',
projectId: 'github:stablyai/orca',
hostId: 'runtime:gpu',
projectHostSetupId: 'setup-gpu',
repoId: 'repo-gpu',
providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' },
accountLabel: 'gpu-bot'
}
queueFixtures(
callMock,
okFixture('req_automation_create', {
automation: { id: 'auto-1', name: 'GPU task review' }
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'automations',
'create',
'--name',
'GPU task review',
'--trigger',
'daily',
'--prompt',
'Review open work',
'--provider',
'codex',
'--repo',
'id:repo-gpu',
'--source-context',
JSON.stringify(sourceContext),
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenNthCalledWith(
1,
'automation.create',
expect.objectContaining({
repo: 'id:repo-gpu',
sourceContext
})
)
})
it('clears automation source context on edit with null', async () => {
queueFixtures(
callMock,
okFixture('req_edit', {
automation: { id: 'auto-1', name: 'GPU task review' }
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['automations', 'edit', 'auto-1', '--source-context', 'null', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenNthCalledWith(
1,
'automation.update',
expect.objectContaining({
id: 'auto-1',
updates: expect.objectContaining({
sourceContext: null
})
})
)
})
it('rejects invalid automation source context JSON before calling the runtime', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'automations',
'create',
'--name',
'GPU task review',
'--trigger',
'daily',
'--prompt',
'Review open work',
'--provider',
'codex',
'--repo',
'id:repo-gpu',
'--source-context',
'{nope',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'--source-context must be valid JSON'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
})

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