Merge remote-tracking branch 'origin/main' into improve-vps-setup

# Conflicts:
#	src/renderer/src/i18n/locales/en.json
This commit is contained in:
Jinwoo-H
2026-08-10 02:26:13 -04:00
240 changed files with 9268 additions and 2062 deletions
+471
View File
@@ -0,0 +1,471 @@
name: Daily macOS Dev Build
# Why: gives developers a signed macOS build of main once a day that the in-app
# updater can install directly, without waiting for an RC cut — less noise than
# 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
# 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.
# - 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
# a ticket every daily reads as a new client and silently loses file access
# under Documents/Desktop/Downloads.
#
# Artifacts publish to stablyai/orca-daily, never to stablyai/orca: the main
# repo's releases atom feed exposes only its 10 newest entries, so high-volume
# dev tags would evict every stable/RC entry and break updates for real users.
# Separate from orca-hourly so someone riding main's hourlies never sees the
# sparse daily series mixed into that list.
#
# GITHUB_TOKEN is scoped to this repo and cannot publish there, so writes use the
# same GitHub App as hourly (installed on orca-daily with Contents: Read and
# write). Provision the App secrets with
# `bash config/scripts/setup-hourly-release-token.sh`, then grant the App access
# to orca-daily with `bash config/scripts/setup-daily-release-repo.sh`:
# HOURLY_RELEASE_APP_ID the App's numeric id (shared with hourly/adhoc)
# HOURLY_RELEASE_APP_PRIVATE_KEY the App's .pem private key
#
# Installation tokens live one hour, which is why this mints three times. Install
# and build need no token at all, and notarization can hold the publish step for
# tens of minutes; minting again once the build is done starts the clock at the
# first call that actually uses it rather than burning a third of it on
# `pnpm install`. The upload step's own retry budget (2x45m) can outlive that
# second token, so a third is minted after it for verify/publish/prune/cleanup —
# without it a stuck notary run would strand a draft that cleanup gets a 401 on.
# The release stays an unpublished draft until the manifest check passes, so the
# worst case is still an invisible draft — and the build-number query counts
# drafts, so it holds its number and the next run does not reuse it.
on:
schedule:
# Once a day, early morning Pacific. Single cron — no DST twin, no clock gate.
- cron: '15 14 * * *'
workflow_dispatch:
inputs:
force:
description: Build even if main has not moved since the last daily
required: false
default: false
type: boolean
permissions:
contents: read
concurrency:
group: daily-mac-build
cancel-in-progress: false
env:
DAILY_REPO: stablyai/orca-daily
# Keep ~30 days so a regression can be bisected without holding a full year.
DAILY_RETAIN_COUNT: 30
jobs:
build-daily-mac:
if: github.repository == 'stablyai/orca'
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
# the job is killed mid-retry and no cleanup step runs at all. A typical run
# is far shorter — this is the notary queue's tail, not its median.
timeout-minutes: 150
env:
NODE_OPTIONS: --max-old-space-size=4096
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
# Why: this job only reads stablyai/orca and never pushes; every write
# goes to the daily repo through a minted App token passed by env.
# Not persisting the checkout credential shrinks the blast radius if a
# build step is compromised (zizmor: artipacked).
persist-credentials: false
- name: Mint daily repo token
id: app_token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }}
private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }}
owner: stablyai
repositories: orca-daily
# Why: main is often idle. Rebuilding an unchanged commit burns a runner
# hour and adds a redundant tag to the retention window.
- name: Check whether main moved since the last daily
id: freshness
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
FORCED: ${{ github.event_name == 'workflow_dispatch' && inputs.force }}
run: |
set -euo pipefail
head_sha="$(git rev-parse HEAD)"
echo "head_sha=$head_sha" >>"$GITHUB_OUTPUT"
if [[ "$FORCED" == "true" ]]; then
echo "should_build=true" >>"$GITHUB_OUTPUT"
echo "Forced dispatch; building $head_sha."
exit 0
fi
# The previous daily records its source commit in the release body.
# Drafts are excluded: an unpublished leftover never shipped, so treating
# it as "the last build" would skip a build that never actually happened.
last_body="$(gh release list --repo "$DAILY_REPO" --limit 20 --json tagName,isDraft \
--jq 'map(select(.isDraft | not)) | .[0].tagName // empty' 2>/dev/null || true)"
if [[ -z "$last_body" ]]; then
echo "should_build=true" >>"$GITHUB_OUTPUT"
echo "No prior daily release found; building $head_sha."
exit 0
fi
last_sha="$(gh release view "$last_body" --repo "$DAILY_REPO" --json body \
--jq '.body | capture("commit `(?<sha>[0-9a-f]{7,40})`") | .sha' 2>/dev/null || true)"
if [[ -n "$last_sha" && "$head_sha" == "$last_sha"* ]]; then
echo "should_build=false" >>"$GITHUB_OUTPUT"
echo "main is unchanged since $last_body ($last_sha); skipping."
else
echo "should_build=true" >>"$GITHUB_OUTPUT"
echo "main moved to $head_sha (last daily built $last_sha); building."
fi
- name: Setup pnpm
if: steps.freshness.outputs.should_build == 'true'
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
if: steps.freshness.outputs.should_build == 'true'
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Cache electron-builder downloads
if: steps.freshness.outputs.should_build == 'true'
uses: actions/cache@v5
with:
path: |
~/Library/Caches/electron
~/Library/Caches/electron-builder
key: electron-builder-mac-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
electron-builder-mac-
- name: Install dependencies
if: steps.freshness.outputs.should_build == 'true'
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile
# Why: signing is what makes a daily installable over an existing Orca, so
# a missing cert must fail here rather than after a 20-minute build.
- name: Verify macOS signing environment
if: steps.freshness.outputs.should_build == 'true'
run: node config/scripts/verify-macos-release-env.mjs
env:
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Compute daily version
id: daily
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
MAIN_REPO_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Existing titles, which carry the build number this series continues
# from. The script picks the number, because it restarts per base version
# and only the script knows which base this build resolved to.
#
# Why drafts count here but not in the freshness check: that check asks
# "did this commit ship", where a draft is a no. This one asks "is the
# number free", where a stranded draft still holds one.
names="$(gh release list --repo "$DAILY_REPO" --limit 200 --json name \
--jq '.[].name // empty')"
# Why the main repo's tags decide the base version rather than
# package.json: main's version only moves on `release:` commits, and
# stable patches are cut from release branches that never merge back, so
# package.json can sit several patches behind what users are running. A
# separate token because GH_TOKEN above is the App's, scoped to the
# daily repo. Empty on failure — the script then falls back to
# package.json, which is stale but never wrong enough to fail a build.
published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \
--repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \
--json tagName --jq '.[].tagName' || true)"
echo "Highest published tag seen: $(head -1 <<<"$published")"
ORCA_PUBLISHED_VERSIONS="$published" ORCA_DAILY_RELEASE_NAMES="$names" \
node config/scripts/daily-build-version.mjs \
>"$RUNNER_TEMP/daily-identity.txt"
grep -E '^(version|build_number)=' "$RUNNER_TEMP/daily-identity.txt"
# Why check rather than trust: the checkout above pins `ref: main`, but a
# workflow_dispatch runs this file from whatever branch was dispatched. A
# branch that edits this step while main still has the old script yields
# an empty name and an untitled release — silent, and only visible once
# someone opens the releases page. Fail here instead.
if ! grep -q '^name=' "$RUNNER_TEMP/daily-identity.txt"; then
echo "::error::daily-build-version.mjs emitted no release name; this workflow and main's copy of the script are out of sync."
exit 1
fi
cat "$RUNNER_TEMP/daily-identity.txt" >>"$GITHUB_OUTPUT"
- name: Build app
if: steps.freshness.outputs.should_build == 'true'
run: pnpm build:release
env:
NODE_OPTIONS: --max-old-space-size=4096
# Why: daily builds are not an official channel — telemetry's transport
# gate accepts only 'stable' or 'rc', so leaving this unset keeps them
# silent, which is correct for unvetted dev artifacts.
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
# Why a second mint: everything from here on writes to the daily repo, and
# the notary round trip inside the publish step can be tens of minutes. The
# token minted at the top has already spent install + build of its one hour
# on steps that never touched it; restarting the clock here gives the slow
# part the full budget.
- name: Re-mint daily repo token for publish
id: app_token_publish
if: steps.freshness.outputs.should_build == 'true'
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }}
private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }}
owner: stablyai
repositories: orca-daily
- name: Create daily release
id: release
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
TAG: v${{ steps.daily.outputs.version }}
NAME: ${{ steps.daily.outputs.name }}
SHA: ${{ steps.freshness.outputs.head_sha }}
run: |
set -euo pipefail
# Kept at 12 even though the title shows 7: the freshness check above
# parses this back out of the body to decide whether main has moved.
short_sha="${SHA:0:12}"
# Why a file rather than an inline string: the publish step re-asserts
# this same body, and the freshness check only works if the two agree
# exactly. One source, written once, read twice.
notes_file="$RUNNER_TEMP/daily-release-notes.md"
cat >"$notes_file" <<EOF
Automated daily macOS dev build from commit \`$short_sha\`.
Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA).
**Unvetted.** No tests ran. Signed and notarized like a release, so it
installs through Orca's in-app updater and opens from a manual download
without a Gatekeeper prompt — but nothing here has been reviewed.
EOF
# Why create it up front: electron-builder then uploads into a known tag
# rather than inferring one from package.json.
#
# Why --draft: everything between here and the manifest check is a window
# where the release exists but has no installable assets. A draft is
# absent from the releases list and from listReleaseBuilds, so a job that
# dies in that window — including a hard kill by the job timeout, which
# runs no cleanup step at all — leaves something invisible rather than a
# tag the picker offers and the download 404s on. It is flipped live only
# after the manifest is verified.
gh release create "$TAG" \
--repo "$DAILY_REPO" \
--title "$NAME" \
--draft \
--notes-file "$notes_file"
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
echo "notes_file=$notes_file" >>"$GITHUB_OUTPUT"
- name: Publish daily macOS artifacts
if: steps.freshness.outputs.should_build == 'true'
uses: nick-fields/retry@v4
with:
# Why 45 like the release pipeline: an attempt is pack + notarize +
# upload, and the notary queue is the unbounded part. Why 2 attempts and
# not 3: a missed daily costs a day, and a third attempt buys less than
# it costs in runner time once the notary is that stuck.
timeout_minutes: 45
max_attempts: 2
retry_wait_seconds: 30
command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_DAILY=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always
env:
# Why: electron-builder's github publisher targets the repo named in the
# config; the token must therefore carry write access to orca-daily.
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
ORCA_DAILY_BUILD_VERSION: ${{ steps.daily.outputs.version }}
ORCA_BUILD_COMMIT: ${{ steps.daily.outputs.commit }}
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
# Why all three: electron-builder's notarize step authenticates to the
# Apple notary service with the app-specific password, not with the
# signing cert. Omitting them fails the build rather than skipping it,
# since `notarize` is now on for this path.
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Why a third mint: the upload step above allows two 45-minute attempts, so
# it can outlive the one-hour token minted before it. Every remaining step
# writes to the daily repo, including the failure path that discards the
# draft — a 401 there is exactly the stranded draft nobody can clean up.
#
# Why always(): a failed or cancelled upload is the case that needs this
# most, since the cleanup step below runs only on that path.
- name: Re-mint daily repo token for verify and cleanup
id: app_token_final
if: always() && steps.freshness.outputs.should_build == 'true'
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }}
private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }}
owner: stablyai
repositories: orca-daily
# Why: the updater resolves a tag, then fetches latest-mac.yml from it. A
# release missing that manifest is a tag the picker offers and the download
# 404s on, so fail loudly instead of leaving a broken entry.
- name: Verify update manifest published
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_final.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
assets="$(gh release view "$TAG" --repo "$DAILY_REPO" --json assets --jq '.assets[].name')"
echo "Published assets:"
echo "$assets"
# Why exit 1 without deleting here: the release is still a draft, so it is
# already invisible to users, and the failure handler below owns cleanup.
# Deleting inline under `set -e` would also let the delete's exit code
# preempt this explicit failure.
for required in latest-mac.yml; do
if ! grep -qx "$required" <<<"$assets"; then
echo "::error::Daily draft $TAG is missing $required; the updater could not install it."
exit 1
fi
done
if ! grep -q '\.zip$' <<<"$assets"; then
echo "::error::Daily draft $TAG has no ZIP artifact for the updater to download."
exit 1
fi
# Why this is the last mutating step: publishing the draft is what makes the
# build visible to listReleaseBuilds. Doing it only after the manifest check
# means the picker can never offer a release whose assets are incomplete.
- name: Publish the verified release
id: publish_live
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_final.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
NAME: ${{ steps.daily.outputs.name }}
NOTES_FILE: ${{ steps.release.outputs.notes_file }}
run: |
set -euo pipefail
# --title again: electron-builder resolves this draft by tag and may
# rewrite its title on upload. Re-asserting here means the name the
# picker reads is the one composed above, whatever it did in between.
# --notes-file for the same reason, and it matters more: the next run's
# freshness check parses the source commit back out of this body, so a
# body the publisher overwrote would rebuild an unchanged main daily.
gh release edit "$TAG" --repo "$DAILY_REPO" --draft=false --prerelease \
--title "$NAME" --notes-file "$NOTES_FILE"
echo "Published $TAG as \"$NAME\""
# Why: a draft left behind by a failed publish is invisible to users but still
# holds its tag name, so the next run for the same minute would collide.
#
# Why it is gated on publish_live not having succeeded: a later failure (the
# prune step) must not delete a release that already went live and that users
# may already be installing. A job killed by the outer timeout runs no steps
# at all — which is exactly why the release stays a draft until verified.
# Why cancelled() too: a run stopped from the Actions UI is not a failure(),
# so without it a manual cancel mid-publish would strand the draft.
- name: Discard the draft release on failure
if: >-
(failure() || cancelled()) && steps.release.outputs.tag != '' &&
steps.publish_live.outcome != 'success'
shell: bash
env:
# Fall back to the publish token: if the re-mint itself is what failed,
# the older token is the only one left and may still have time on it.
GH_TOKEN: ${{ steps.app_token_final.outputs.token || steps.app_token_publish.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
set -uo pipefail
# No --cleanup-tag: an unpublished draft never created a git tag.
echo "Run failed before publish; discarding draft $TAG"
gh release delete "$TAG" --repo "$DAILY_REPO" --yes ||
echo "::warning::Could not discard draft $TAG; remove it manually."
- name: Prune old daily releases
# Only after a live publish: $TAG is then a non-draft we must not delete,
# and failed runs should not reshuffle retention around a draft that the
# failure path is about to discard.
if: steps.publish_live.outcome == 'success'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_final.outputs.token }}
# Protect the tag this run just shipped; at the retain cap, a bad sort
# can otherwise mark the newest release as stale and delete it.
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
# Why: --cleanup-tag so pruning does not leave orphan tags behind that
# keep showing up in tag lists with no release or assets attached.
# Drafts are excluded so retention counts shipped builds only; a stale
# draft is handled by the failure path, not by the retention window.
#
# Sort by publishedAt (not createdAt). Many non-draft dailies can share
# one createdAt (bulk import / re-create), so createdAt ranking is
# unstable. publishedAt is real recency; tagName (...YYYYMMDDHHMM) is
# the deterministic tie-break.
#
# Why force $TAG to the front before slicing: a hard retain-window seat
# for this run's release. Dropping $TAG from the list *before* the slice
# would permanently keep retain+1 releases; skipping it only in the
# delete loop would under-prune when the sort is still wrong. Partition
# keeps relative order of every other tag.
jq_filter='map(select(.isDraft | not)) | sort_by(.publishedAt // "", .tagName) | reverse'
if [[ -n "${TAG:-}" ]]; then
jq_filter+=" | (map(select(.tagName == \"${TAG//\"/\\\"}\")) + map(select(.tagName != \"${TAG//\"/\\\"}\")))"
fi
jq_filter+=" | .[${DAILY_RETAIN_COUNT}:] | .[].tagName"
stale="$(gh release list --repo "$DAILY_REPO" --limit 200 --json tagName,publishedAt,isDraft \
--jq "$jq_filter")"
if [[ -z "$stale" ]]; then
echo "Nothing to prune; at or under $DAILY_RETAIN_COUNT retained builds."
exit 0
fi
while read -r tag; do
[[ -n "$tag" ]] || continue
# Belt-and-suspenders: partition above should already exclude $TAG.
if [[ -n "${TAG:-}" && "$tag" == "$TAG" ]]; then
echo "::warning::Prune list still included just-published $tag after protect; skipping delete."
continue
fi
echo "Pruning $tag"
gh release delete "$tag" --repo "$DAILY_REPO" --yes --cleanup-tag || \
echo "::warning::Could not prune $tag"
done <<<"$stale"
+18 -7
View File
@@ -20,21 +20,32 @@ const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.
// Developer ID signature, and notarization ticket — or Squirrel.Mac refuses to
// swap them over an installed Orca and macOS treats each build as a new app.
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 || 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
? process.env.ORCA_HOURLY_BUILD_VERSION
: isMacAdhoc
? process.env.ORCA_ADHOC_BUILD_VERSION
: undefined
: isMacDaily
? process.env.ORCA_DAILY_BUILD_VERSION
: isMacAdhoc
? process.env.ORCA_ADHOC_BUILD_VERSION
: undefined
// Why each dev channel gets its own repo rather than tagging into the main one:
// the releases atom feed exposes only the 10 newest entries, so 24 hourly tags a
// day would evict every stable/RC entry and strand users on a feed with nothing
// to install. Keeping adhoc separate from hourly too means a branch build cannot
// be picked up by someone who only meant to ride main.
const devChannelRepo = isMacHourly ? 'orca-hourly' : isMacAdhoc ? 'orca-adhoc' : null
// to install. Keeping adhoc/daily separate from hourly too means a branch build
// or a once-a-day cut cannot be picked up by someone who only meant to ride
// main's hourlies.
const devChannelRepo = isMacHourly
? 'orca-hourly'
: isMacDaily
? 'orca-daily'
: isMacAdhoc
? 'orca-adhoc'
: null
const appId = 'com.stablyai.orca'
const featureWallResources = {
from: 'resources/onboarding/feature-wall',
+109
View File
@@ -0,0 +1,109 @@
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs'
import {
readPublishedVersionsFromEnv,
resolveDevChannelBaseVersion
} from './dev-channel-base-version.mjs'
/** `1.4.160-daily.202607281300` — UTC to the minute, so tags sort chronologically
* by semver and every build is uniquely versioned. */
export function createDailyBuildVersion(baseVersion, date) {
const match = /^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(baseVersion)
if (!match) {
throw new Error(`Package version is not valid semver: ${baseVersion}`)
}
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
throw new Error('Daily build timestamp is invalid.')
}
const pad = (value, width = 2) => String(value).padStart(width, '0')
const stamp = [
pad(date.getUTCFullYear(), 4),
pad(date.getUTCMonth() + 1),
pad(date.getUTCDate()),
pad(date.getUTCHours()),
pad(date.getUTCMinutes())
].join('')
// Why: drop any -rc.N tail. Keeping it makes every daily semver-NEWER than the
// RC it was cut from (1.4.160-rc.3-daily.X > 1.4.160-rc.3), which would let an
// ordinary RC-channel check offer untested daily builds to RC users. Stripping
// to the base parks dailies below both rc.N and stable ('daily' < 'rc'
// alphabetically), reachable only by an explicit pinned jump.
return `${match[1]}-daily.${stamp}`
}
/**
* The next build number for `baseVersion`, counting from the titles of existing
* daily releases.
*
* Why the series restarts at 01 on every base version: the number answers "which
* build of 1.4.163 is this", so a counter shared across versions makes it
* meaningless — 1.4.164 would open at 38 for no reason a reader can see.
*
* Why the maximum rather than a count: the prune step trims to
* DAILY_RETAIN_COUNT, so a count would roll backwards and reissue a number
* already in use. Titles that predate this naming simply do not match, which is
* how the first build of a version lands on 01.
*/
export function nextDailyBuildNumber(baseVersion, releaseNames = []) {
const prefix = `${baseVersion} • `
const highest = releaseNames.reduce((max, entry) => {
const name = String(entry ?? '')
if (!name.startsWith(prefix)) {
return max
}
const match = /^(\d+) • /.exec(name.slice(prefix.length))
return match ? Math.max(max, Number(match[1])) : max
}, 0)
return highest + 1
}
/**
* `1.4.163 • 01 • Aug 9, 6: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) {
if (!Number.isInteger(buildNumber) || buildNumber < 1) {
throw new Error(`Daily build number must be a positive integer: ${buildNumber}`)
}
return [
version.split('-')[0],
String(buildNumber).padStart(2, '0'),
formatReleaseTitleTimestamp(date),
commit.slice(0, 7)
].join(' • ')
}
// Why the number is derived here rather than passed in: it counts builds of the
// base version, and the base is only known once the published tags have been
// resolved just above. Computing it outside meant numbering against whatever
// version the caller guessed.
export function getDailyBuildIdentity(now = new Date(), { publishedVersions, releaseNames } = {}) {
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
encoding: 'utf8'
}).trim()
const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions ?? [])
const version = createDailyBuildVersion(base, now)
const buildNumber = nextDailyBuildNumber(base, releaseNames ?? [])
return {
commit,
version,
buildNumber,
name: formatDailyReleaseName(version, buildNumber, commit, now)
}
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
const identity = getDailyBuildIdentity(new Date(), {
publishedVersions: readPublishedVersionsFromEnv(),
// Titles are newline separated and contain spaces, so this cannot reuse the
// whitespace split the version list gets.
releaseNames: (process.env.ORCA_DAILY_RELEASE_NAMES ?? '').split('\n').filter(Boolean)
})
// Consumed by the workflow via $GITHUB_OUTPUT.
process.stdout.write(
`version=${identity.version}\ncommit=${identity.commit}\nbuild_number=${identity.buildNumber}\nname=${identity.name}\n`
)
}
+111
View File
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'
import {
createDailyBuildVersion,
formatDailyReleaseName,
nextDailyBuildNumber
} from './daily-build-version.mjs'
import { compareAppVersions } from '../../src/shared/app-version'
describe('createDailyBuildVersion', () => {
it('stamps the version with a zero-padded UTC timestamp', () => {
expect(createDailyBuildVersion('1.4.160', new Date('2026-07-28T13:00:00Z'))).toBe(
'1.4.160-daily.202607281300'
)
})
// Why: main's package.json carries the in-flight RC tail. Keeping it would make
// every daily semver-NEWER than the RC it was cut from (1.4.160-rc.3-daily.X >
// 1.4.160-rc.3), so an ordinary RC-channel check would offer untested daily
// builds to RC users. Dropping it parks dailies below both rc.N and stable,
// reachable only by an explicit pinned jump.
it('drops an in-flight rc tail so dailies never outrank the rc series', () => {
const version = createDailyBuildVersion('1.4.160-rc.3', new Date('2026-07-28T13:00:00Z'))
expect(version).toBe('1.4.160-daily.202607281300')
expect(compareAppVersions(version, '1.4.160-rc.3')).toBeLessThan(0)
expect(compareAppVersions('1.4.160-rc.3-daily.202607281300', '1.4.160-rc.3')).toBeGreaterThan(0)
})
// Why: 'daily' sorts before 'hourly' alphabetically, so a daily of the same
// base never outranks an hourly — both stay below rc/stable and only the
// channel picker offers them.
it('sorts below the hourly build of the same base version', () => {
expect(
compareAppVersions('1.4.160-daily.202607281300', '1.4.160-hourly.202607281400')
).toBeLessThan(0)
})
it('rejects invalid input', () => {
expect(() => createDailyBuildVersion('nope', new Date())).toThrow(/valid semver/)
expect(() => createDailyBuildVersion('1.4.160', new Date('nope'))).toThrow(/invalid/)
})
})
describe('formatDailyReleaseName', () => {
const name = (iso, buildNumber = 1, commit = 'e698241abcde') =>
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')
})
// 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')
})
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 • ')
})
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/)
})
it('rejects an invalid timestamp', () => {
expect(() => formatDailyReleaseName('1.4.163', 1, 'abcdefg', new Date('nope'))).toThrow(
/invalid/
)
})
})
describe('nextDailyBuildNumber', () => {
const titles = [
'1.4.163 • 01 • Jul 28, 6:00AM • e698241',
'1.4.163 • 02 • Jul 29, 6:00AM • aaaaaaa',
'1.4.163 • 09 • Aug 01, 6:00AM • bbbbbbb'
]
it('continues the series for the version being built', () => {
expect(nextDailyBuildNumber('1.4.163', titles)).toBe(10)
})
it('restarts at 1 when the base version moves', () => {
expect(nextDailyBuildNumber('1.4.164', titles)).toBe(1)
expect(
nextDailyBuildNumber('1.4.164', [...titles, '1.4.164 • 01 • Aug 02, 6:00AM • ccccccc'])
).toBe(2)
})
// Why max and not count: pruning trims to DAILY_RETAIN_COUNT, so counting
// would roll backwards and reissue a number already used.
it('takes the highest number, not the count', () => {
expect(nextDailyBuildNumber('1.4.163', ['1.4.163 • 09 • Jul 31, 6:00AM • e698241'])).toBe(10)
})
it('starts at 1 with no history at all', () => {
expect(nextDailyBuildNumber('1.4.163')).toBe(1)
expect(nextDailyBuildNumber('1.4.163', [])).toBe(1)
})
it('ignores titles that are not this version', () => {
expect(nextDailyBuildNumber('1.4.16', ['1.4.163 • 09 • Aug 01, 6:00AM • bbbbbbb'])).toBe(1)
expect(nextDailyBuildNumber('1.4.163', ['v1.4.163-daily.202607311300', null, ''])).toBe(1)
})
})
@@ -19,42 +19,6 @@ const {
verifyPackagedMainRuntimeDeps
} = require('../packaged-runtime-node-modules.cjs')
const MUTABLE_BUILD_ENV = [
'ORCA_MAC_HOURLY',
'ORCA_MAC_ADHOC',
'ORCA_MAC_RELEASE',
'ORCA_HOURLY_BUILD_VERSION',
'ORCA_ADHOC_BUILD_VERSION',
'ORCA_LOCAL_BUILD_VERSION'
]
/** Re-requires the config under a temporary env, then restores env and module cache. */
function withEnv(env, assert) {
const configPath = require.resolve('../electron-builder.config.cjs')
const original = Object.fromEntries(MUTABLE_BUILD_ENV.map((key) => [key, process.env[key]]))
try {
for (const key of MUTABLE_BUILD_ENV) {
delete process.env[key]
}
Object.assign(process.env, env)
delete require.cache[configPath]
assert(require('../electron-builder.config.cjs'))
} finally {
for (const [key, value] of Object.entries(original)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
delete require.cache[configPath]
require('../electron-builder.config.cjs')
}
}
const withHourlyEnv = (assert) => withEnv({ ORCA_MAC_HOURLY: '1' }, assert)
const withAdhocEnv = (assert) => withEnv({ ORCA_MAC_ADHOC: '1' }, assert)
describe('electron-builder config', () => {
it('keeps the packaged app identity aligned with local-build validation', () => {
expect(electronBuilderConfig.appId).toBe(
@@ -303,89 +267,6 @@ describe('electron-builder config', () => {
}
})
// Why: Squirrel.Mac swaps the .app in place only when the replacement carries the
// same bundle id and a valid Developer ID signature. A hourly built on the local
// (com.stablyai.orca.local, ad-hoc) identity would be un-installable over a real
// Orca — the whole point of the channel.
it('builds hourly artifacts with the release signing identity', () => {
withHourlyEnv((config) => {
expect(config.mac.appId).toBeUndefined()
expect(config.appId).toBe('com.stablyai.orca')
expect(config.mac.hardenedRuntime).toBe(true)
expect(config.forceCodeSigning).toBe(true)
})
})
// Why hourly must notarize despite the round trip: TCC anchors a notarized
// Developer ID app's grants on identifier + team, not on its cdhash, so they
// survive an update. An unnotarized hourly reads as a new client every build
// and loses file access under Documents/Desktop/Downloads with no re-prompt.
it('notarizes hourly builds like releases, and neither locally', () => {
withHourlyEnv((config) => {
expect(config.mac.notarize).toBe(true)
})
withEnv({ ORCA_MAC_RELEASE: '1' }, (config) => {
expect(config.mac.notarize).toBe(true)
})
expect(electronBuilderConfig.mac.notarize).toBe(false)
})
// Why: the main repo's releases atom feed exposes only its 10 newest entries.
// Publishing 24 hourly tags a day there would evict every stable/RC entry and
// break update checks for every real user.
it('publishes hourly builds to the separate hourly repo', () => {
withHourlyEnv((config) => {
expect(config.publish).toMatchObject({ repo: 'orca-hourly', releaseType: 'prerelease' })
})
expect(electronBuilderConfig.publish).toMatchObject({
repo: 'orca',
releaseType: 'release'
})
})
it('stamps hourly packages with the hourly version', () => {
withEnv(
{ ORCA_MAC_HOURLY: '1', ORCA_HOURLY_BUILD_VERSION: '1.4.160-hourly.202607281400' },
(config) => {
expect(config.extraMetadata).toEqual({ version: '1.4.160-hourly.202607281400' })
}
)
})
// Why adhoc carries the identical mac identity to hourly: it installs over a
// real Orca through the same updater path, so the same signing and the same TCC
// argument apply. Only the destination repo differs.
it('builds adhoc artifacts with the release identity and its own repo', () => {
withAdhocEnv((config) => {
expect(config.appId).toBe('com.stablyai.orca')
expect(config.mac.hardenedRuntime).toBe(true)
expect(config.mac.notarize).toBe(true)
expect(config.forceCodeSigning).toBe(true)
expect(config.publish).toMatchObject({ repo: 'orca-adhoc', releaseType: 'prerelease' })
})
})
it('stamps adhoc packages with the adhoc version', () => {
withEnv(
{ ORCA_MAC_ADHOC: '1', ORCA_ADHOC_BUILD_VERSION: '1.4.160-adhoc.20260728140533' },
(config) => {
expect(config.extraMetadata).toEqual({ version: '1.4.160-adhoc.20260728140533' })
}
)
})
// Why: the two dev channels share every packaging decision except where they
// publish, so a future edit that collapses them must not also collapse the
// repos — a branch build landing in orca-hourly would be offered to everyone
// riding main.
it('keeps the two dev channels on separate repos', () => {
withHourlyEnv((hourly) => {
withAdhocEnv((adhoc) => {
expect(hourly.publish.repo).not.toBe(adhoc.publish.repo)
})
})
})
it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => {
expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild)
expect(electronBuilderConfig.npmRebuild).toBe(true)
@@ -0,0 +1,152 @@
import { createRequire } from 'node:module'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const electronBuilderConfig = require('../electron-builder.config.cjs')
const MUTABLE_BUILD_ENV = [
'ORCA_MAC_HOURLY',
'ORCA_MAC_DAILY',
'ORCA_MAC_ADHOC',
'ORCA_MAC_RELEASE',
'ORCA_HOURLY_BUILD_VERSION',
'ORCA_DAILY_BUILD_VERSION',
'ORCA_ADHOC_BUILD_VERSION',
'ORCA_LOCAL_BUILD_VERSION'
]
/** Re-requires the config under a temporary env, then restores env and module cache. */
function withEnv(env, assert) {
const configPath = require.resolve('../electron-builder.config.cjs')
const original = Object.fromEntries(MUTABLE_BUILD_ENV.map((key) => [key, process.env[key]]))
try {
for (const key of MUTABLE_BUILD_ENV) {
delete process.env[key]
}
Object.assign(process.env, env)
delete require.cache[configPath]
assert(require('../electron-builder.config.cjs'))
} finally {
for (const [key, value] of Object.entries(original)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
delete require.cache[configPath]
require('../electron-builder.config.cjs')
}
}
const withHourlyEnv = (assert) => withEnv({ ORCA_MAC_HOURLY: '1' }, assert)
const withDailyEnv = (assert) => withEnv({ ORCA_MAC_DAILY: '1' }, assert)
const withAdhocEnv = (assert) => withEnv({ ORCA_MAC_ADHOC: '1' }, assert)
describe('electron-builder mac channel config', () => {
// Why: Squirrel.Mac swaps the .app in place only when the replacement carries the
// same bundle id and a valid Developer ID signature. A hourly built on the local
// (com.stablyai.orca.local, ad-hoc) identity would be un-installable over a real
// Orca — the whole point of the channel.
it('builds hourly artifacts with the release signing identity', () => {
withHourlyEnv((config) => {
expect(config.mac.appId).toBeUndefined()
expect(config.appId).toBe('com.stablyai.orca')
expect(config.mac.hardenedRuntime).toBe(true)
expect(config.forceCodeSigning).toBe(true)
})
})
// Why hourly must notarize despite the round trip: TCC anchors a notarized
// Developer ID app's grants on identifier + team, not on its cdhash, so they
// survive an update. An unnotarized hourly reads as a new client every build
// and loses file access under Documents/Desktop/Downloads with no re-prompt.
it('notarizes hourly builds like releases, and neither locally', () => {
withHourlyEnv((config) => {
expect(config.mac.notarize).toBe(true)
})
withEnv({ ORCA_MAC_RELEASE: '1' }, (config) => {
expect(config.mac.notarize).toBe(true)
})
expect(electronBuilderConfig.mac.notarize).toBe(false)
})
// Why: the main repo's releases atom feed exposes only its 10 newest entries.
// Publishing 24 hourly tags a day there would evict every stable/RC entry and
// break update checks for every real user.
it('publishes hourly builds to the separate hourly repo', () => {
withHourlyEnv((config) => {
expect(config.publish).toMatchObject({ repo: 'orca-hourly', releaseType: 'prerelease' })
})
expect(electronBuilderConfig.publish).toMatchObject({
repo: 'orca',
releaseType: 'release'
})
})
it('stamps hourly packages with the hourly version', () => {
withEnv(
{ ORCA_MAC_HOURLY: '1', ORCA_HOURLY_BUILD_VERSION: '1.4.160-hourly.202607281400' },
(config) => {
expect(config.extraMetadata).toEqual({ version: '1.4.160-hourly.202607281400' })
}
)
})
// Why adhoc carries the identical mac identity to hourly: it installs over a
// real Orca through the same updater path, so the same signing and the same TCC
// argument apply. Only the destination repo differs.
it('builds adhoc artifacts with the release identity and its own repo', () => {
withAdhocEnv((config) => {
expect(config.appId).toBe('com.stablyai.orca')
expect(config.mac.hardenedRuntime).toBe(true)
expect(config.mac.notarize).toBe(true)
expect(config.forceCodeSigning).toBe(true)
expect(config.publish).toMatchObject({ repo: 'orca-adhoc', releaseType: 'prerelease' })
})
})
it('stamps adhoc packages with the adhoc version', () => {
withEnv(
{ ORCA_MAC_ADHOC: '1', ORCA_ADHOC_BUILD_VERSION: '1.4.160-adhoc.20260728140533' },
(config) => {
expect(config.extraMetadata).toEqual({ version: '1.4.160-adhoc.20260728140533' })
}
)
})
it('builds daily artifacts with the release identity and its own repo', () => {
withDailyEnv((config) => {
expect(config.appId).toBe('com.stablyai.orca')
expect(config.mac.hardenedRuntime).toBe(true)
expect(config.mac.notarize).toBe(true)
expect(config.forceCodeSigning).toBe(true)
expect(config.publish).toMatchObject({ repo: 'orca-daily', releaseType: 'prerelease' })
})
})
it('stamps daily packages with the daily version', () => {
withEnv(
{ ORCA_MAC_DAILY: '1', ORCA_DAILY_BUILD_VERSION: '1.4.160-daily.202607281300' },
(config) => {
expect(config.extraMetadata).toEqual({ version: '1.4.160-daily.202607281300' })
}
)
})
// Why: the dev channels share every packaging decision except where they
// publish, so a future edit that collapses them must not also collapse the
// repos — a branch or daily build landing in orca-hourly would be offered to
// everyone riding main's hourlies.
it('keeps the dev channels on separate repos', () => {
withHourlyEnv((hourly) => {
withDailyEnv((daily) => {
withAdhocEnv((adhoc) => {
expect(new Set([hourly.publish.repo, daily.publish.repo, adhoc.publish.repo]).size).toBe(
3
)
})
})
})
})
})
@@ -0,0 +1,147 @@
#!/usr/bin/env node
// Benchmarks optioned localeCompare calls used by renderer list comparators.
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
import { createJiti } from 'jiti'
const ROUND_COUNT = 5
const MIN_ROUND_MS = 120
const jiti = createJiti(import.meta.url, {
alias: { '@': fileURLToPath(new URL('../../src/renderer/src', import.meta.url)) }
})
const { compareBaseSensitivityLocaleText } = await jiti.import(
'../../src/renderer/src/lib/locale-text-collators.ts'
)
const { sortJiraIssues } = await jiti.import(
'../../src/renderer/src/components/jira-issue-sorter.ts'
)
let randomState = 0x9e3779b9
function random() {
randomState = (Math.imul(randomState, 1664525) + 1013904223) >>> 0
return randomState / 0x100000000
}
function shuffle(values) {
for (let index = values.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(random() * (index + 1))
;[values[index], values[swapIndex]] = [values[swapIndex], values[index]]
}
return values
}
const labels = ['alpha', 'Álpha', 'beta', 'BÉTA', 'café', 'Cafe', 'zeta', 'Ångström']
function makeJiraIssues(count) {
return shuffle(
Array.from({ length: count }, (_, index) => ({
key: `TASK-${(index * 37) % (count + 17)}`,
title: labels[index % labels.length],
updatedAt: '2026-01-01T00:00:00.000Z'
}))
)
}
function makeBaseSensitivityValues(count) {
return shuffle(
Array.from(
{ length: count },
(_, index) =>
`${labels[(index * 5) % labels.length]}-${String((index * 41) % (count + 23)).padStart(4, '0')}`
)
)
}
function calibrate(run) {
for (let index = 0; index < 5; index += 1) {
run()
}
let iterations = 1
while (true) {
const startedAt = performance.now()
for (let index = 0; index < iterations; index += 1) {
run()
}
if (performance.now() - startedAt >= MIN_ROUND_MS) {
break
}
iterations *= 2
}
return iterations
}
function measureRound(run, iterations) {
const startedAt = performance.now()
for (let index = 0; index < iterations; index += 1) {
run()
}
return (performance.now() - startedAt) / iterations
}
function measurePair(before, after) {
const beforeIterations = calibrate(before)
const afterIterations = calibrate(after)
const beforeSamples = []
const afterSamples = []
for (let round = 0; round < ROUND_COUNT; round += 1) {
if (round % 2 === 0) {
beforeSamples.push(measureRound(before, beforeIterations))
afterSamples.push(measureRound(after, afterIterations))
} else {
afterSamples.push(measureRound(after, afterIterations))
beforeSamples.push(measureRound(before, beforeIterations))
}
}
const middle = Math.floor(ROUND_COUNT / 2)
return {
beforeMs: beforeSamples.sort((a, b) => a - b)[middle],
afterMs: afterSamples.sort((a, b) => a - b)[middle]
}
}
function assertSameOrder(before, after, label) {
const expected = before()
const actual = after()
if (
expected.length !== actual.length ||
expected.some((value, index) => value !== actual[index])
) {
throw new Error(`${label} sort order changed`)
}
}
const pad = (value, width) => String(value).padStart(width)
console.log('Renderer locale sort, ms per sort (median of 5 rounds). Lower is better.')
console.log(
`${pad('mode', 9)} ${pad('items', 7)} ${pad('per-call', 11)} ${pad('reused', 11)} ${pad('speedup', 9)}`
)
for (const count of [36, 50, 250]) {
const issues = makeJiraIssues(count)
const before = () =>
[...issues]
.sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }))
.map((issue) => issue.key)
const after = () => sortJiraIssues(issues, 'key', 'asc').map((issue) => issue.key)
assertSameOrder(before, after, `numeric ${count}`)
const { beforeMs, afterMs } = measurePair(before, after)
console.log(
`${pad('numeric', 9)} ${pad(count, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}`
)
}
for (const count of [10, 50, 250]) {
const values = makeBaseSensitivityValues(count)
const before = () =>
[...values].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
const after = () => [...values].sort(compareBaseSensitivityLocaleText)
assertSameOrder(before, after, `base ${count}`)
const { beforeMs, afterMs } = measurePair(before, after)
console.log(
`${pad('base', 9)} ${pad(count, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}`
)
}
console.log(
'\n36 rows matches the Linear page size, 50 matches the picker/Jira scale, and\n250 is a stress case. Both arms assert identical output before timing.'
)
+1 -1
View File
@@ -86,7 +86,7 @@ Could not do it from here${INSTALL_ID:+ (needs an Organization Owner)}. Do it in
1. Open: https://github.com/organizations/$ORG/settings/installations
2. Configure -> $APP_SLUG
3. Repository access -> Only select repositories -> add $ADHOC_REPO
(keep orca-hourly selected; both dev channels use this one App)
(keep orca-hourly/orca-daily selected; all dev channels use this one App)
4. Save.
EOF
fi
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
#
# Creates stablyai/orca-daily and grants the existing release App write access to
# it, so daily-mac-build.yml can publish there.
#
# Why a separate repo rather than reusing orca-hourly: the daily channel is a
# once-a-day cut that people ride deliberately. Sharing hourly's list would mix a
# sparse daily series into the 72-entry hourly retention window and make both
# pickers harder to read.
#
# Why no secrets are set here: the daily workflow reuses the same GitHub App as
# hourly/adhoc — one App id, one private key, one thing to rotate. This script
# only has to widen that App's installation to cover the new repo.
#
# Run once, after config/scripts/setup-hourly-release-token.sh:
# bash config/scripts/setup-daily-release-repo.sh
#
set -euo pipefail
ORG="stablyai"
DAILY_REPO="$ORG/orca-daily"
MAIN_REPO="$ORG/orca"
APP_SLUG="orca-hourly-release"
fail() {
echo "error: $*" >&2
exit 1
}
command -v gh >/dev/null 2>&1 || fail "gh CLI not found. See https://cli.github.com"
gh auth status >/dev/null 2>&1 || fail "Not logged in. Run: gh auth login"
if gh api "repos/$DAILY_REPO" --jq '.full_name' >/dev/null 2>&1; then
echo "$DAILY_REPO already exists."
else
echo "Creating $DAILY_REPO..."
# Why public: the in-app updater fetches release assets unauthenticated, exactly
# as it does for orca-hourly. A private repo would 404 for every client.
#
# Why the features are off: this repo holds releases and nothing else. Leaving
# issues open invites bug reports against an unvetted daily in a repo nobody
# watches, where they are simply lost.
#
# Why --add-readme in a repo with no source: publishing a release creates a tag,
# and a tag needs a commit. Empty repo = "Repository is empty" 25 minutes in.
gh repo create "$DAILY_REPO" \
--public \
--description "Daily macOS dev builds of Orca, cut from main each morning. Not a source repo." \
--add-readme \
--disable-issues \
--disable-wiki ||
fail "Could not create $DAILY_REPO."
fi
# Also checked outside the create branch: a repo made before --add-readme is here.
if ! gh api "repos/$DAILY_REPO/commits" --jq 'length' >/dev/null 2>&1; then
fail "$DAILY_REPO has no commits — releases cannot be tagged. Add any file to it first."
fi
echo
echo "Granting $APP_SLUG access to $DAILY_REPO..."
# Why attempt the API before printing instructions: an org owner can do this in
# one call. Everyone else gets a 403 and the manual path below — GitHub does not
# let a mere admin widen an App's repository selection.
INSTALL_ID="$(gh api "orgs/$ORG/installations" --paginate \
--jq ".installations[] | select(.app_slug == \"$APP_SLUG\") | .id" 2>/dev/null || true)"
REPO_ID="$(gh api "repos/$DAILY_REPO" --jq '.id' 2>/dev/null || true)"
GRANTED=false
if [[ -n "$INSTALL_ID" && -n "$REPO_ID" ]]; then
if gh api -X PUT "user/installations/$INSTALL_ID/repositories/$REPO_ID" >/dev/null 2>&1; then
GRANTED=true
echo "Done — $APP_SLUG can now write to $DAILY_REPO."
fi
fi
if [[ "$GRANTED" != "true" ]]; then
# Why no automated check afterwards: the endpoints that report an App's
# repository access (repos/*/installation, user/installations/*/repositories)
# both reject an ordinary `gh auth login` token, so any "verified" this script
# printed would be guesswork. The smoke test below is the real check.
cat <<EOF
Could not do it from here${INSTALL_ID:+ (needs an Organization Owner)}. Do it in the browser:
1. Open: https://github.com/organizations/$ORG/settings/installations
2. Configure -> $APP_SLUG
3. Repository access -> Only select repositories -> add $DAILY_REPO
(keep orca-hourly and orca-adhoc selected; all dev channels use this one App)
4. Save.
EOF
fi
echo
echo "Smoke-test the pipeline (after this merges):"
echo " gh workflow run daily-mac-build.yml --repo $MAIN_REPO -f force=true"
echo " gh run watch --repo $MAIN_REPO"
+4 -3
View File
@@ -8,9 +8,10 @@
# — no yearly rotation — and it belongs to the org rather than to the person who
# created it, so it survives that person leaving.
#
# The same App also serves adhoc-mac-build.yml, which reads these same two
# secrets: one credential, one rotation, both dev channels. Widening it to cover
# stablyai/orca-adhoc is config/scripts/setup-adhoc-release-repo.sh's job.
# The same App also serves adhoc-mac-build.yml and daily-mac-build.yml, which
# read these same two secrets: one credential, one rotation, all dev channels.
# Widening it to cover stablyai/orca-adhoc / orca-daily is
# setup-adhoc-release-repo.sh / setup-daily-release-repo.sh's job.
#
# The key is read from a file and piped straight into `gh secret set`. It is never
# echoed, never passed as a command-line argument (argv is world-readable via
@@ -0,0 +1,117 @@
#!/usr/bin/env node
// Run with: node config/scripts/worktree-base-pending-marker-benchmark.mjs
import fs from 'node:fs'
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { syncBuiltinESMExports } from 'node:module'
import os from 'node:os'
import { join } from 'node:path'
import { performance } from 'node:perf_hooks'
import { createJiti } from 'jiti'
const CANDIDATE_COUNT = Number(process.env.ORCA_PENDING_MARKER_BENCH_CANDIDATES ?? '64')
const TOTAL_TICKS = Number(process.env.ORCA_PENDING_MARKER_BENCH_TICKS ?? '900')
const STEADY_TICKS = Number(process.env.ORCA_PENDING_MARKER_BENCH_STEADY_TICKS ?? '300')
for (const [name, value] of [
['ORCA_PENDING_MARKER_BENCH_CANDIDATES', CANDIDATE_COUNT],
['ORCA_PENDING_MARKER_BENCH_TICKS', TOTAL_TICKS],
['ORCA_PENDING_MARKER_BENCH_STEADY_TICKS', STEADY_TICKS]
]) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
if (STEADY_TICKS >= TOTAL_TICKS) {
throw new Error('ORCA_PENDING_MARKER_BENCH_STEADY_TICKS must be smaller than total ticks')
}
const root = await mkdtemp(join(os.tmpdir(), 'orca-pending-marker-bench-'))
const markerPaths = new Set()
for (let index = 0; index < CANDIDATE_COUNT; index += 1) {
const candidate = join(root, `ordinary-folder-${index}`)
await mkdir(candidate)
markerPaths.add(join(candidate, '.git'))
}
const originalStat = fs.promises.stat
let visibleTick = 0
const markerStatTicks = []
fs.promises.stat = async (path, ...args) => {
if (markerPaths.has(String(path))) {
markerStatTicks.push(visibleTick)
}
return originalStat(path, ...args)
}
syncBuiltinESMExports()
let poller
let timeout
try {
const jiti = createJiti(import.meta.url)
const { startWorktreeBaseDirectoryPoller } = await jiti.import(
'../../src/main/ipc/worktree-base-directory-poller.ts'
)
const repo = { repoId: 'repo-1', repoName: 'repo', nestWorkspaces: false }
const target = {
key: `base:local:${root}`,
kind: 'base',
path: root,
repos: new Map([[repo.repoId, repo]])
}
let finish
let fail
const completed = new Promise((resolve, reject) => {
finish = resolve
fail = reject
})
timeout = setTimeout(() => fail(new Error('poller benchmark timed out')), 30_000)
const startedAt = performance.now()
poller = await startWorktreeBaseDirectoryPoller(
target,
() => target.repos,
() => {},
{
pollIntervalMs: 0,
visibility: {
isWindowVisible: () => {
visibleTick += 1
if (visibleTick > TOTAL_TICKS) {
finish()
return false
}
return true
},
onWindowBecameVisible: () => () => {}
}
}
)
await completed
clearTimeout(timeout)
await poller.unsubscribe()
poller = undefined
const steadyStartTick = TOTAL_TICKS - STEADY_TICKS
const steadyMarkerStats = markerStatTicks.filter((tick) => tick > steadyStartTick).length
const totalMarkerStats = markerStatTicks.length
const statsPerCandidateTick = steadyMarkerStats / CANDIDATE_COUNT / STEADY_TICKS
const elapsedMs = performance.now() - startedAt
console.log('Worktree-base marker cooldown benchmark. Lower is better.')
console.log(
JSON.stringify({
candidates: CANDIDATE_COUNT,
totalTicks: TOTAL_TICKS,
steadyTicks: STEADY_TICKS,
totalMarkerStats,
steadyMarkerStats,
statsPerCandidateTick: Number(statsPerCandidateTick.toFixed(4)),
elapsedMs: Number(elapsedMs.toFixed(1))
})
)
} finally {
clearTimeout(timeout)
await poller?.unsubscribe()
fs.promises.stat = originalStat
syncBuiltinESMExports()
await rm(root, { recursive: true, force: true })
}
@@ -106,7 +106,7 @@ import type {
TerminalModes,
TerminalWebViewHandle
} from '../../../../src/terminal/terminal-webview-contract'
import { isTerminalOscLinkRanges } from '../../../../src/terminal/terminal-osc-link-ranges'
import { isTerminalOscLinkRanges } from '../../../../../src/shared/terminal-osc-link-ranges'
import { computeActiveTerminalKeyboardLift } from '../../../../src/terminal/terminal-keyboard-avoidance-lift'
import { useTerminalViewportRefit } from '../../../../src/terminal/terminal-viewport-refit'
import {
+1 -1
View File
@@ -1,8 +1,8 @@
import { useMemo, useRef, useState } from 'react'
import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'
import { Check } from 'lucide-react-native'
import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask'
type Props = {
prompt: AskPrompt
@@ -1,8 +1,8 @@
import { createElement } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MAX_TOOL_DETAIL_LENGTH } from '../../../src/shared/native-chat-tool-summary'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { MAX_TOOL_DETAIL_LENGTH } from './mobile-native-chat-tool-summary'
vi.mock('react-native', async () => {
const React = await import('react')
+12 -15
View File
@@ -2,25 +2,22 @@ import { memo, useEffect, useRef, useState } from 'react'
import { Image, Pressable, Text, View } from 'react-native'
import * as Clipboard from 'expo-clipboard'
import { ArrowUp, ChevronDown, Copy, SquareChevronRight } from 'lucide-react-native'
import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types'
import { MobileMarkdown } from '../components/MobileMarkdown'
import { colors } from '../theme/mobile-theme'
import {
isImageRefBlock,
isTextBlock,
pairToolBlocks,
splitNativeChatBlocks,
type ToolPair
} from './mobile-native-chat-blocks'
import { diffFromText, diffFromToolCall, type DiffLine } from './mobile-native-chat-diff'
import { isRenderableImageUri } from './mobile-native-chat-image-preview'
import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles'
import { nativeChatMessageText } from './mobile-native-chat-message-text'
import { diffFromText, diffFromToolCall } from '../../../src/shared/native-chat-diff'
import type { NativeChatDiffLine as DiffLine } from '../../../src/shared/native-chat-diff'
import { pairToolBlocks, splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold'
import type { NativeChatToolPair as ToolPair } from '../../../src/shared/native-chat-tool-fold'
import {
createToolInputDisplay,
summarizeToolRun,
truncateToolDetail
} from './mobile-native-chat-tool-summary'
} from '../../../src/shared/native-chat-tool-summary'
import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types'
import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types'
import { MobileMarkdown } from '../components/MobileMarkdown'
import { colors } from '../theme/mobile-theme'
import { isRenderableImageUri } from './mobile-native-chat-image-preview'
import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles'
import { nativeChatMessageText } from './mobile-native-chat-message-text'
const MAX_VISIBLE_TOOL_PAIRS = 6
const MAX_TOOL_RUN_DIFF_ROWS = 240
+1 -1
View File
@@ -11,6 +11,7 @@ import {
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
import { ArrowDown, ChevronsDownUp, ChevronsUpDown, Square } from 'lucide-react-native'
import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-native-chat-view-styles'
@@ -26,7 +27,6 @@ import { MobileNativeChatComposer } from './MobileNativeChatComposer'
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
import { MobileNativeChatAsk } from './MobileNativeChatAsk'
import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask'
import { MobileNativeChatPermission } from './MobileNativeChatPermission'
import type { MobileChatPermission } from './mobile-native-chat-permission'
import { MobileNativeChatQuestion } from './MobileNativeChatQuestion'
+3 -34
View File
@@ -1,4 +1,7 @@
import type { DiffComment, DiffReviewScope } from '../../../src/shared/types'
import { formatDiffComment, formatDiffComments } from '../../../src/shared/diff-comments-format'
export { formatDiffComment, formatDiffComments }
export type CreateMobileDiffCommentInput = {
worktreeId: string
@@ -16,44 +19,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function isMarkdownComment(comment: Pick<DiffComment, 'source'>): boolean {
return comment.source === 'markdown'
}
function normalizeScope(value: unknown): DiffReviewScope | undefined {
return value === 'unstaged' || value === 'staged' || value === 'branch' ? value : undefined
}
// Why: mobile Vitest/Metro run from the mobile package and cannot transform
// runtime imports from root src/shared. Keep this byte-for-byte compatible with
// the desktop shared formatter contract.
export function formatDiffComment(c: DiffComment): string {
const escaped = c.body
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\r/g, '\\r')
.replace(/\n/g, '\\n')
const locationLabel =
c.lineNumber === 0
? 'Scope: file'
: c.startLine !== undefined && c.startLine !== c.lineNumber
? `Lines: ${c.startLine}-${c.lineNumber}`
: `Line: ${c.lineNumber}`
if (!isMarkdownComment(c)) {
return [`File: ${c.filePath}`, locationLabel, `User comment: "${escaped}"`].join('\n')
}
return [
`File: ${c.filePath}`,
'Source: markdown',
locationLabel,
`User comment: "${escaped}"`
].join('\n')
}
export function formatDiffComments(comments: readonly DiffComment[]): string {
return comments.map(formatDiffComment).join('\n\n')
}
export function formatMobileDiffReviewPrompt(comments: readonly DiffComment[]): string {
return [
'You are reviewing the current worktree. Address the following mobile review notes.',
@@ -1,11 +1,11 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
buildAskAnswerKeys,
extractPendingAsk,
formatAskAnswer,
parseAskFromStatus
} from './mobile-native-chat-ask'
} from '../../../src/shared/native-chat-ask'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
function msg(blocks: NativeChatMessage['blocks'], id = 'm'): NativeChatMessage {
return { id, role: 'assistant', blocks, timestamp: 0, source: 'transcript' }
@@ -1,2 +0,0 @@
// Mobile and desktop share one parser/formatter so question-tool behavior cannot drift.
export * from '../../../src/shared/native-chat-ask'
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
foldToolMessages,
pairToolBlocks,
splitNativeChatBlocks
} from './mobile-native-chat-blocks'
} from '../../../src/shared/native-chat-tool-fold'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
function msg(
role: NativeChatMessage['role'],
@@ -1,11 +0,0 @@
export {
isImageRefBlock,
isTextBlock,
NATIVE_CHAT_SOURCE_PRIORITY
} from '../../../src/shared/native-chat-types'
export {
foldToolMessages,
pairToolBlocks,
splitNativeChatBlocks,
type NativeChatToolPair as ToolPair
} from '../../../src/shared/native-chat-tool-fold'
@@ -1,7 +1,11 @@
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import type {
AskAnswerSelection,
AskPrompt,
parseAskFromStatus
} from '../../../src/shared/native-chat-ask'
import type { detectAgentPermission } from './mobile-native-chat-permission'
import type { parseAgentQuestion } from './mobile-native-chat-question'
import type { AskAnswerSelection, AskPrompt, parseAskFromStatus } from './mobile-native-chat-ask'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts'
import type { useMobileNativeChatSession } from './use-mobile-native-chat-session'
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { diffFromText, diffFromToolCall } from './mobile-native-chat-diff'
import { diffFromText, diffFromToolCall } from '../../../src/shared/native-chat-diff'
describe('diffFromToolCall', () => {
it('builds del/add lines from an Edit tool call', () => {
@@ -36,8 +36,6 @@ describe('diffFromText', () => {
expect(diffFromText('Here is a list:\n- one bullet\nthat is all')).toBeNull()
})
// Parity guard: the mobile module re-exports the shared parser, so the -- / ++
// content collision must stay fixed on both platforms.
it('counts a removed -- comment line despite the --- prefix collision', () => {
const lines = diffFromText('@@ -1,2 +1,2 @@\n ctx\n---sql comment\n+new')
expect(lines).toEqual([
@@ -1,6 +0,0 @@
export {
diffFromText,
diffFromToolCall,
type NativeChatDiffLine as DiffLine,
type NativeChatDiffLineKind as DiffLineKind
} from '../../../src/shared/native-chat-diff'
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { mergeNativeChatMessages } from '../../../src/shared/native-chat-merge'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { mergeNativeChatMessages } from './mobile-native-chat-merge'
function msg(id: string, overrides: Partial<NativeChatMessage> = {}): NativeChatMessage {
return {
@@ -1,9 +0,0 @@
// Keep the established mobile import path while executing the shared desktop/mobile merger.
export {
applyAppend,
boundNativeChatWindow,
createNativeChatMerger,
mergeNativeChatMessages,
replaceList,
type NativeChatMerger
} from '../../../src/shared/native-chat-merge'
@@ -6,7 +6,7 @@ import {
createNativeChatMerger,
mergeNativeChatMessages,
replaceList
} from './mobile-native-chat-merge'
} from '../../../src/shared/native-chat-merge'
function msg(id: string, overrides: Partial<NativeChatMessage> = {}): NativeChatMessage {
return {
@@ -1,5 +1,4 @@
import type { NativeChatBlock } from '../../../src/shared/native-chat-types'
import { isTextBlock } from './mobile-native-chat-blocks'
import { isTextBlock, type NativeChatBlock } from '../../../src/shared/native-chat-types'
/** Concatenate a message's text blocks into a single copyable string. Tool
* calls/results and image refs are skipped — Copy is for the agent's prose. */
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { isNoiseMessage, stripNoiseMessages } from '../../../src/shared/native-chat-noise'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { isNoiseMessage, stripNoiseMessages } from './mobile-native-chat-noise'
function msg(
role: NativeChatMessage['role'],
@@ -1 +0,0 @@
export { isNoiseMessage, stripNoiseMessages } from '../../../src/shared/native-chat-noise'
@@ -3,10 +3,10 @@ import {
formatNativeChatEmptyStateCopy,
type NativeChatEmptyStateCopy
} from '../../../src/shared/native-chat-empty-state'
import { stripNoiseMessages } from '../../../src/shared/native-chat-noise'
import { foldToolMessages } from '../../../src/shared/native-chat-tool-fold'
import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types'
import { foldToolMessages } from './mobile-native-chat-blocks'
import { normalizeImageTranscriptMessages } from './mobile-native-chat-image-transcript-markers'
import { stripNoiseMessages } from './mobile-native-chat-noise'
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
/** The centered empty-state copy for a chat with no messages, mirroring the
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { createNativeChatMerger, replaceList } from '../../../src/shared/native-chat-merge'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { createNativeChatMerger, replaceList } from './mobile-native-chat-merge'
import { applyMobileNativeChatStreamFrame } from './mobile-native-chat-stream-frame'
function message(id: string): NativeChatMessage {
@@ -1,5 +1,6 @@
import { applyAppend, replaceList } from '../../../src/shared/native-chat-merge'
import type { NativeChatMerger } from '../../../src/shared/native-chat-merge'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { applyAppend, replaceList, type NativeChatMerger } from './mobile-native-chat-merge'
export type MobileNativeChatStreamFrame = {
type?: string
@@ -4,7 +4,7 @@ import {
describeToolInput,
summarizeToolInput,
toolFilePath
} from './mobile-native-chat-tool-summary'
} from '../../../src/shared/native-chat-tool-summary'
describe('summarizeToolInput', () => {
it('passes short strings through, collapsing whitespace', () => {
@@ -59,7 +59,7 @@ describe('toolFilePath', () => {
})
describe('describeToolInput', () => {
it('is re-exported and labels rows with the path or primary argument', () => {
it('labels rows with the path or primary argument', () => {
expect(describeToolInput({ file_path: 'src/a.ts', offset: 3 })).toBe('src/a.ts')
expect(describeToolInput('{"cmd":"git status"}')).toBe('git status')
})
@@ -1,13 +0,0 @@
export {
briefToolArg,
countToolCalls,
createToolInputDisplay,
describeToolInput,
formatToolInput,
isStructuredToolInput,
MAX_TOOL_DETAIL_LENGTH,
summarizeToolInput,
summarizeToolRun,
toolFilePath,
truncateToolDetail
} from '../../../src/shared/native-chat-tool-summary'
@@ -1,11 +1,11 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AskPrompt } from '../../../src/shared/native-chat-ask'
import type { AgentType } from '../../../src/shared/native-chat-types'
import type { RpcClient } from '../transport/rpc-client'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping'
import type { AskPrompt } from './mobile-native-chat-ask'
import {
isMobileNativeChatInputStale,
markMobileNativeChatInputStale,
@@ -1,6 +1,4 @@
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping'
import {
buildAskAnswerKeys,
buildCodexAskAnswerKeys,
@@ -8,7 +6,9 @@ import {
hasAskAnswer,
type AskAnswerSelection,
type AskPrompt
} from './mobile-native-chat-ask'
} from '../../../src/shared/native-chat-ask'
import type { RpcClient } from '../transport/rpc-client'
import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping'
import {
openMobileNativeChatSendBudget,
sendMobileNativeChatMessageWithOutcome
@@ -1,7 +1,7 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { AskPrompt } from './mobile-native-chat-ask'
import type { AskPrompt } from '../../../src/shared/native-chat-ask'
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
describe('useMobileNativeChatAskDismiss', () => {
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { nativeChatAskDismissKey, type AskPrompt } from './mobile-native-chat-ask'
import { nativeChatAskDismissKey, type AskPrompt } from '../../../src/shared/native-chat-ask'
type AskDismissal = { sessionKey: string | null; askKey: string }
type DetectedAsk = { sessionKey: string | null; askKey: string | null }
@@ -1,7 +1,7 @@
import { useMemo } from 'react'
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
import { parseAskFromStatus, resolveNativeChatAsk } from '../../../src/shared/native-chat-ask'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { parseAskFromStatus, resolveNativeChatAsk } from './mobile-native-chat-ask'
import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission'
import { parseAgentQuestion } from './mobile-native-chat-question'
@@ -3,10 +3,10 @@ import {
createNativeChatTranscriptRetention,
encodeNativeChatTranscriptIdentity
} from '../../../src/shared/native-chat-transcript-retention'
import { createNativeChatMerger, replaceList } from '../../../src/shared/native-chat-merge'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { buildNativeChatSubscriptionId } from '../../../src/shared/native-chat-stream-unsubscribe'
import type { RpcClient } from '../transport/rpc-client'
import { createNativeChatMerger, replaceList } from './mobile-native-chat-merge'
import {
applyMobileNativeChatStreamFrame,
type MobileNativeChatStreamFrame
@@ -2,7 +2,6 @@ import { hostedReviewCopy } from './hosted-review-copy'
import {
buildMobileHostedReviewCreateParams,
createMobileHostedReview,
fetchMobileHostedReviewEligibility,
mobileRepoSelectorFromWorktreeId,
resolveMobileHostedReviewPrefill,
shouldPushBeforeMobileHostedReviewCreate,
@@ -20,7 +19,6 @@ export type MobilePrCreateOutcome = MobileHostedReviewCreateOutcome
export {
buildMobileHostedReviewCreateParams as buildMobilePrCreateParams,
createMobileHostedReview as createMobilePr,
fetchMobileHostedReviewEligibility as fetchMobilePrEligibility,
mobileRepoSelectorFromWorktreeId,
resolveMobileHostedReviewPrefill as resolveMobilePrPrefill,
shouldPushBeforeMobileHostedReviewCreate as shouldPushBeforeMobilePrCreate
+1 -1
View File
@@ -1,7 +1,7 @@
import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react'
import { Platform, View } from 'react-native'
import { WebView, type WebViewMessageEvent } from 'react-native-webview'
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges'
import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract'
import {
TerminalWebViewEngineErrorOverlay,
@@ -1,18 +0,0 @@
import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges'
export type { TerminalOscLinkRange }
export function isTerminalOscLinkRanges(value: unknown): value is TerminalOscLinkRange[] {
return (
Array.isArray(value) &&
value.every(
(entry) =>
entry != null &&
typeof entry === 'object' &&
Number.isInteger((entry as TerminalOscLinkRange).row) &&
Number.isInteger((entry as TerminalOscLinkRange).startCol) &&
Number.isInteger((entry as TerminalOscLinkRange).endCol) &&
typeof (entry as TerminalOscLinkRange).uri === 'string'
)
)
}
@@ -1,6 +1,6 @@
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges'
import type { StyleProp, ViewStyle } from 'react-native'
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any'
@@ -1,5 +1,5 @@
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges'
export type TerminalWebViewCommand =
| { type: 'ping'; id?: number }
@@ -1044,11 +1044,7 @@ private func screenCaptureTrusted() -> Bool {
}
private func screenCaptureTrustedSettled() -> Bool {
PermissionTrustSettling.settleWithFallback(
finalTimeoutMs: 500,
probe: screenCaptureTrusted,
fallbackProbe: screenCaptureTrustedByCaptureProbe
)
PermissionTrustSettling.settle(timeoutMs: 2_000, probe: screenCaptureTrusted).settled
}
private func permissionStatusSnapshotSettled() -> PermissionStatusSnapshot {
@@ -1058,40 +1054,6 @@ private func permissionStatusSnapshotSettled() -> PermissionStatusSnapshot {
)
}
private func screenCaptureTrustedByCaptureProbe() -> Bool {
guard let infos = CGWindowListCopyWindowInfo(
[.optionOnScreenOnly],
kCGNullWindowID
) as? [[String: Any]] else {
return false
}
let windows = infos.compactMap { info -> ScreenCaptureProbeWindow? in
guard let layer = info[kCGWindowLayer as String] as? Int,
let ownerPid = info[kCGWindowOwnerPID as String] as? NSNumber,
let number = info[kCGWindowNumber as String] as? NSNumber
else {
return nil
}
return ScreenCaptureProbeWindow(
layer: layer,
ownerPid: ownerPid.int32Value,
windowId: number.uint32Value
)
}
guard let windowId = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow(
ownPid: ProcessInfo.processInfo.processIdentifier,
windows: windows
) else {
return false
}
return CGWindowListCreateImage(
.null,
[.optionIncludingWindow],
CGWindowID(windowId),
[.boundsIgnoreFraming]
) != nil
}
private func requestScreenCaptureAccess() -> Bool {
CGRequestScreenCaptureAccess()
}
@@ -41,33 +41,4 @@ public enum PermissionTrustSettling {
waitedMs += interval
}
}
public static func settleWithFallback(
initialTimeoutMs: Int = defaultTimeoutMs,
finalTimeoutMs: Int,
intervalMs: Int = defaultIntervalMs,
sleepMs: (Int) -> Void = { interval in
Thread.sleep(forTimeInterval: TimeInterval(interval) / 1_000)
},
probe: () -> Bool,
fallbackProbe: () -> Bool
) -> Bool {
if settle(
timeoutMs: initialTimeoutMs,
intervalMs: intervalMs,
sleepMs: sleepMs,
probe: probe
).settled {
return true
}
if fallbackProbe() {
return true
}
return settle(
timeoutMs: finalTimeoutMs,
intervalMs: intervalMs,
sleepMs: sleepMs,
probe: probe
).settled
}
}
@@ -1,22 +0,0 @@
public struct ScreenCaptureProbeWindow: Equatable {
public let layer: Int
public let ownerPid: Int32
public let windowId: UInt32
public init(layer: Int, ownerPid: Int32, windowId: UInt32) {
self.layer = layer
self.ownerPid = ownerPid
self.windowId = windowId
}
}
public enum ScreenCaptureProbeWindowSelection {
public static func firstCrossProcessNormalWindow(
ownPid: Int32,
windows: [ScreenCaptureProbeWindow]
) -> UInt32? {
windows.first { window in
window.layer == 0 && window.ownerPid != ownPid
}?.windowId
}
}
@@ -0,0 +1,31 @@
import XCTest
final class ScreenCapturePermissionPreflightSafetyTests: XCTestCase {
func testPermissionPreflightCannotRequestOrCaptureTheScreen() throws {
let source = try computerUseSource()
let start = try XCTUnwrap(source.range(of: "private func screenCaptureTrusted()"))
let end = try XCTUnwrap(
source.range(of: "private func requestScreenCaptureAccess()", range: start.upperBound..<source.endIndex)
)
let preflightSource = source[start.lowerBound..<end.lowerBound]
XCTAssertTrue(preflightSource.contains("CGPreflightScreenCaptureAccess()"))
XCTAssertTrue(preflightSource.contains("timeoutMs: 2_000"))
XCTAssertFalse(preflightSource.contains("CGRequestScreenCaptureAccess()"))
XCTAssertFalse(preflightSource.contains("CGWindowListCreateImage"))
XCTAssertFalse(preflightSource.contains("SCScreenshotManager"))
}
private func computerUseSource() throws -> String {
let testFile = URL(fileURLWithPath: #filePath)
let packageRoot = testFile
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
let mainPath = packageRoot
.appendingPathComponent("Sources")
.appendingPathComponent("OrcaComputerUseMacOS")
.appendingPathComponent("main.swift")
return try String(contentsOf: mainPath, encoding: .utf8)
}
}
@@ -1,79 +0,0 @@
import Testing
@testable import OrcaComputerUseMacOSCore
@Suite("Screen Capture permission settling")
struct ScreenCapturePermissionSettlingTests {
@Test("initial preflight success skips fallback")
func initialSuccess() {
var fallbackCalls = 0
let trusted = PermissionTrustSettling.settleWithFallback(
finalTimeoutMs: 500,
sleepMs: { _ in },
probe: { true },
fallbackProbe: {
fallbackCalls += 1
return true
}
)
#expect(trusted)
#expect(fallbackCalls == 0)
}
@Test("capture fallback can establish trust")
func fallbackSuccess() {
var fallbackCalls = 0
let trusted = PermissionTrustSettling.settleWithFallback(
initialTimeoutMs: 200,
finalTimeoutMs: 200,
intervalMs: 100,
sleepMs: { _ in },
probe: { false },
fallbackProbe: {
fallbackCalls += 1
return true
}
)
#expect(trusted)
#expect(fallbackCalls == 1)
}
@Test("preflight is retried after fallback failure")
func finalPreflightSuccess() {
var probeCalls = 0
let trusted = PermissionTrustSettling.settleWithFallback(
initialTimeoutMs: 100,
finalTimeoutMs: 200,
intervalMs: 100,
sleepMs: { _ in },
probe: {
probeCalls += 1
return probeCalls == 4
},
fallbackProbe: { false }
)
#expect(trusted)
#expect(probeCalls == 4)
}
@Test("persistent denial remains denied")
func persistentDenial() {
var fallbackCalls = 0
let trusted = PermissionTrustSettling.settleWithFallback(
initialTimeoutMs: 100,
finalTimeoutMs: 100,
intervalMs: 100,
sleepMs: { _ in },
probe: { false },
fallbackProbe: {
fallbackCalls += 1
return false
}
)
#expect(!trusted)
#expect(fallbackCalls == 1)
}
}
@@ -1,44 +0,0 @@
import Testing
@testable import OrcaComputerUseMacOSCore
@Suite("Screen capture probe window selection")
struct ScreenCaptureProbeWindowSelectionTests {
@Test("own process windows cannot prove cross-app capture permission")
func excludesOwnWindows() {
let selected = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow(
ownPid: 42,
windows: [
.init(layer: 0, ownerPid: 42, windowId: 1),
.init(layer: 0, ownerPid: 84, windowId: 2)
]
)
#expect(selected == 2)
}
@Test("non-normal windows are skipped")
func excludesNonNormalWindows() {
let selected = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow(
ownPid: 42,
windows: [
.init(layer: 1, ownerPid: 84, windowId: 1),
.init(layer: 0, ownerPid: 84, windowId: 2)
]
)
#expect(selected == 2)
}
@Test("no cross-process normal window returns nil")
func noCandidate() {
let selected = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow(
ownPid: 42,
windows: [
.init(layer: 0, ownerPid: 42, windowId: 1),
.init(layer: 1, ownerPid: 84, windowId: 2)
]
)
#expect(selected == nil)
}
}
+36
View File
@@ -122,6 +122,42 @@ describe('AgentAwakeService', () => {
expect(linuxAssertion.start).toHaveBeenCalledTimes(1)
})
it('stays awake in On mode without a working agent', () => {
const blocker = createBlocker()
const service = createService(() => 1_000, blocker)
service.setMode('on')
expect(blocker.start).toHaveBeenCalledWith('prevent-display-sleep')
expect(service.getStatus()).toEqual({ mode: 'on', active: true })
})
it('publishes Auto activity changes to status subscribers', () => {
const service = createService(() => 1_000)
const listener = vi.fn()
service.subscribe(listener)
service.setMode('auto')
service.setStatuses([workingStatus()])
service.setStatuses([workingStatus(), workingStatus()])
service.setStatuses([workingStatus()])
service.setStatuses([])
expect(listener).toHaveBeenNthCalledWith(1, {
mode: 'auto',
active: false
})
expect(listener).toHaveBeenNthCalledWith(2, {
mode: 'auto',
active: true
})
expect(listener).toHaveBeenNthCalledWith(3, {
mode: 'auto',
active: false
})
expect(listener).toHaveBeenCalledTimes(3)
})
it('starts and stops from settings flips around an already-running status', () => {
const blocker = createBlocker()
const macosAssertion = createMacosAssertion()
+50 -10
View File
@@ -1,5 +1,10 @@
import { powerMonitor, powerSaveBlocker } from 'electron'
import type { AgentStatusState } from '../shared/agent-status-types'
import {
normalizeComputerAwakeMode,
type ComputerAwakeMode,
type ComputerAwakeStatus
} from '../shared/computer-awake-mode'
import { LinuxLidSleepAssertion } from './linux-lid-sleep-assertion'
import { MacosSystemSleepAssertion } from './macos-system-sleep-assertion'
@@ -40,10 +45,12 @@ type AgentAwakeServiceOptions = {
}
export class AgentAwakeService {
private enabled = false
private mode: ComputerAwakeMode = 'off'
private statuses: AgentAwakeStatus[] = []
private blockerId: number | null = null
private staleTimer: ReturnType<typeof setTimeout> | null = null
private readonly statusListeners = new Set<(status: ComputerAwakeStatus) => void>()
private lastPublishedStatus: ComputerAwakeStatus | null = null
private readonly blocker: PowerSaveBlocker
private readonly linuxAssertion: PlatformAwakeAssertion
private readonly logger: Logger
@@ -82,10 +89,15 @@ export class AgentAwakeService {
}
setEnabled(enabled: boolean): void {
if (this.enabled === enabled) {
this.setMode(enabled ? 'auto' : 'off')
}
setMode(mode: ComputerAwakeMode): void {
const normalized = normalizeComputerAwakeMode(mode)
if (this.mode === normalized) {
return
}
this.enabled = enabled
this.mode = normalized
this.refresh('settings-change')
}
@@ -94,6 +106,19 @@ export class AgentAwakeService {
this.refresh('status-change')
}
getStatus(): ComputerAwakeStatus {
const workingAgentCount = this.getEligibleRunningStatusCount()
return {
mode: this.mode,
active: this.mode === 'on' || (this.mode === 'auto' && workingAgentCount > 0)
}
}
subscribe(listener: (status: ComputerAwakeStatus) => void): () => void {
this.statusListeners.add(listener)
return () => this.statusListeners.delete(listener)
}
dispose(): void {
this.clearStaleTimer()
this.unsubscribeResume?.()
@@ -105,7 +130,7 @@ export class AgentAwakeService {
private refresh(reason: string): void {
this.scheduleStaleTimer()
const runningStatusCount = this.getEligibleRunningStatusCount()
const shouldBlock = this.enabled && runningStatusCount > 0
const shouldBlock = this.mode === 'on' || (this.mode === 'auto' && runningStatusCount > 0)
if (shouldBlock) {
this.startBlocker(reason, runningStatusCount)
this.startMacosAssertion(reason)
@@ -115,6 +140,21 @@ export class AgentAwakeService {
this.stopMacosAssertion(reason)
this.stopLinuxAssertion(reason)
}
this.publishStatus(shouldBlock)
}
private publishStatus(active: boolean): void {
const status = { mode: this.mode, active }
if (
this.lastPublishedStatus?.mode === status.mode &&
this.lastPublishedStatus.active === status.active
) {
return
}
this.lastPublishedStatus = status
for (const listener of this.statusListeners) {
listener(status)
}
}
private getEligibleRunningStatusCount(): number {
@@ -182,7 +222,7 @@ export class AgentAwakeService {
} catch (err) {
this.logger.warn('[agent-awake] failed to start blocker', {
reason,
enabled: this.enabled,
mode: this.mode,
runningStatusCount,
error: err
})
@@ -195,7 +235,7 @@ export class AgentAwakeService {
} catch (err) {
this.logger.warn('[agent-awake] failed to start macOS system sleep assertion', {
reason,
enabled: this.enabled,
mode: this.mode,
error: err
})
}
@@ -207,7 +247,7 @@ export class AgentAwakeService {
} catch (err) {
this.logger.warn('[agent-awake] failed to start Linux lid sleep assertion', {
reason,
enabled: this.enabled,
mode: this.mode,
error: err
})
}
@@ -219,7 +259,7 @@ export class AgentAwakeService {
} catch (err) {
this.logger.warn('[agent-awake] failed to stop macOS system sleep assertion', {
reason,
enabled: this.enabled,
mode: this.mode,
error: err
})
}
@@ -231,7 +271,7 @@ export class AgentAwakeService {
} catch (err) {
this.logger.warn('[agent-awake] failed to stop Linux lid sleep assertion', {
reason,
enabled: this.enabled,
mode: this.mode,
error: err
})
}
@@ -247,7 +287,7 @@ export class AgentAwakeService {
} catch (err) {
this.logger.warn('[agent-awake] failed to stop blocker', {
reason,
enabled: this.enabled,
mode: this.mode,
runningStatusCount,
blockerId: id,
error: err
@@ -233,6 +233,13 @@ describe('WslHookRelayManager', () => {
return { manager: new WslHookRelayManager(deps), deps }
}
it('exposes the stable guest endpoint instance key with a port fallback', () => {
expect(createManager({}).manager.getInstanceKey()).toBe('testinstance')
expect(createManager({ instanceKey: () => 'unsafe/key' }).manager.getInstanceKey()).toBe(
'port43117'
)
})
it('starts one relay per distro, installs hooks, exposes the guest endpoint path, and forwards envelopes', async () => {
const { manager, deps } = createManager({})
manager.ensureForDistro('Ubuntu')
+10 -2
View File
@@ -109,6 +109,15 @@ export class WslHookRelayManager {
return this.stateFor(distro)?.opencodeOverlayDir ?? null
}
getInstanceKey(): string | null {
const stableKey = sanitizeWslHookInstanceKey(this.deps.instanceKey() ?? undefined)
if (stableKey) {
return stableKey
}
const port = Number(this.deps.hookCoordsEnv().ORCA_AGENT_HOOK_PORT ?? '')
return Number.isInteger(port) && port > 0 ? `port${port}` : null
}
/** Kills every live relay. Non-permanent (hooks switched off mid-session) leaves the
* manager reusable, so re-enabling hooks can start relays again without an app restart. */
disposeAll({ permanent = true }: { permanent?: boolean } = {}): void {
@@ -172,8 +181,7 @@ export class WslHookRelayManager {
}
// Why: restart-stable instance identity keeps the guest endpoint file at
// ONE path across restarts so daemon-surviving agents re-coordinate.
const instanceKey =
sanitizeWslHookInstanceKey(this.deps.instanceKey() ?? undefined) ?? `port${port}`
const instanceKey = this.getInstanceKey() ?? `port${port}`
if (existing) {
this.recovery.clearTimers(existing)
}
+5
View File
@@ -604,6 +604,8 @@ describePosix('daemon shell-ready launch config', () => {
'[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"'
const agentTeamsPathRestoreLine = '[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0'
const ompWrapperLine = 'command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"'
const primeWrapperLine =
'command prime-agent --extension "${ORCA_PRIME_AGENT_STATUS_EXTENSION}" "$@"'
expect(zshrc).toContain(restoreLine)
expect(zlogin).toContain(restoreLine)
expect(bashRc).toContain(restoreLine)
@@ -625,6 +627,9 @@ describePosix('daemon shell-ready launch config', () => {
expect(zshrc).toContain(ompWrapperLine)
expect(zlogin).toContain(ompWrapperLine)
expect(bashRc).toContain(ompWrapperLine)
expect(zshrc).toContain(primeWrapperLine)
expect(zlogin).toContain(primeWrapperLine)
expect(bashRc).toContain(primeWrapperLine)
})
// Why: regression guard for issue #2422 — bash wrapper must emit OSC 133 C/D so SSH sessions clear stale 'working' agent rows.
+4
View File
@@ -11,6 +11,7 @@ import {
isPowerShellExecutableName
} from '../powershell-osc133-bootstrap'
import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
import { getPosixPrimeAgentShellWrapper } from '../pty/prime-agent-shell-wrapper'
import {
getFishShellReadyInitCommand,
getZshEnvTemplate,
@@ -122,6 +123,7 @@ __orca_restore_agent_teams_path
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
${getPosixOmpShellWrapper()}
${getPosixPrimeAgentShellWrapper()}
# Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts.
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
# Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent
@@ -243,6 +245,7 @@ if [[ ! -o login ]]; then
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
${getPosixOmpShellWrapper()}
${getPosixPrimeAgentShellWrapper()}
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
fi
__orca_osc133_precmd() {
@@ -306,6 +309,7 @@ __orca_restore_agent_teams_path
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
${getPosixOmpShellWrapper()}
${getPosixPrimeAgentShellWrapper()}
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER)}
${getZshFinalZdotdirRestoreBlock()}
@@ -225,10 +225,14 @@ describe('GitHub PR local runtime routing', () => {
return { stdout: '[]' }
}
if (endpoint.endsWith('/pulls/7/comments/11/replies')) {
return { stdout: JSON.stringify({ id: 12, user: null, body: 'Reply' }) }
return {
stdout: JSON.stringify({ id: 12, node_id: 'PRRC_reply_12', user: null, body: 'Reply' })
}
}
if (endpoint.endsWith('/pulls/7/comments')) {
return { stdout: JSON.stringify({ id: 13, user: null, body: 'Inline' }) }
return {
stdout: JSON.stringify({ id: 13, node_id: 'PRRC_inline_13', user: null, body: 'Inline' })
}
}
return { stdout: '', stderr: '' }
})
@@ -250,7 +254,10 @@ describe('GitHub PR local runtime routing', () => {
prRepo,
localGitOptions
)
).resolves.toMatchObject({ ok: true })
).resolves.toMatchObject({
ok: true,
comment: { reactionSubjectId: 'PRRC_reply_12' }
})
await expect(
addPRReviewComment({
repoPath: '/repo-root',
@@ -263,7 +270,10 @@ describe('GitHub PR local runtime routing', () => {
path: 'src/app.ts',
line: 10
})
).resolves.toMatchObject({ ok: true })
).resolves.toMatchObject({
ok: true,
comment: { reactionSubjectId: 'PRRC_inline_13' }
})
await expect(
updatePRTitle('/repo-root', 7, 'New title', null, prRepo, localGitOptions)
).resolves.toBe(true)
+162
View File
@@ -178,6 +178,7 @@ import {
getPullRequestPushTarget,
mergePR,
resolveReviewThread,
setPRCommentReaction,
setPRAutoMerge,
updatePRState,
updatePRTitle,
@@ -4043,6 +4044,60 @@ describe('GitHub GraphQL rate-limit guard', () => {
expect(noteRateLimitSpendMock).not.toHaveBeenCalledWith('graphql')
})
it('maps review summary reaction subjects from GraphQL', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify({
data: {
repository: {
pullRequest: {
reviewThreads: { nodes: [] },
comments: { nodes: [] },
reviews: {
nodes: [
{
id: 'PRR_44',
databaseId: 44,
author: {
__typename: 'Bot',
login: 'coderabbitai',
avatarUrl: 'https://avatar'
},
body: 'Automated review summary',
createdAt: '2026-04-01T00:00:00Z',
url: 'https://github.com/acme/widgets/pull/7#pullrequestreview-44',
reactionGroups: [
{
content: 'ROCKET',
viewerHasReacted: true,
reactors: { totalCount: 2 }
}
]
}
]
}
}
}
}
})
})
.mockResolvedValueOnce({ stdout: '[]' })
.mockResolvedValueOnce({ stdout: '[]' })
await expect(getPRComments('/repo-root', 7)).resolves.toEqual([
expect.objectContaining({
id: 44,
reactionSubjectId: 'PRR_44',
isBot: true,
reactions: [{ content: 'rocket', count: 2, viewerHasReacted: true }]
})
])
expect(ghExecFileAsyncMock.mock.calls[0]?.[0]).toEqual(
expect.arrayContaining([expect.stringContaining('reviews(first: 100)')])
)
})
it('uses explicit PR repo for comments when a fork PR is discovered', async () => {
rateLimitGuardMock.mockImplementation(((bucket: string) =>
bucket === 'graphql'
@@ -4082,6 +4137,113 @@ describe('GitHub GraphQL rate-limit guard', () => {
)
})
it('returns GraphQL reaction subjects and viewer state for PR comments', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify({
data: {
repository: {
pullRequest: {
comments: {
nodes: [
{
id: 'IC_1',
databaseId: 10,
author: { login: 'octo', avatarUrl: '', __typename: 'User' },
body: 'Issue comment',
createdAt: '2026-04-01T00:00:00Z',
url: 'https://example.test/issue-comment',
reactionGroups: [
{
content: 'THUMBS_UP',
viewerHasReacted: true,
reactors: { totalCount: 2 }
}
]
}
]
},
reviewThreads: {
nodes: [
{
id: 'PRRT_1',
isResolved: false,
line: 4,
startLine: null,
originalLine: 4,
originalStartLine: null,
comments: {
nodes: [
{
id: 'PRRC_1',
databaseId: 11,
author: { login: 'reviewer', avatarUrl: '', __typename: 'User' },
body: 'Inline comment',
createdAt: '2026-04-01T00:01:00Z',
url: 'https://example.test/review-comment',
path: 'src/app.ts',
reactionGroups: [
{
content: 'EYES',
viewerHasReacted: false,
reactors: { totalCount: 1 }
}
]
}
]
}
}
]
}
}
}
}
})
})
.mockResolvedValueOnce({ stdout: '[]' })
.mockResolvedValueOnce({ stdout: '[]' })
const comments = await getPRComments('/repo-root', 7, {
prRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' }
})
expect(comments).toEqual([
expect.objectContaining({
id: 10,
reactionSubjectId: 'IC_1',
reactions: [{ content: '+1', count: 2, viewerHasReacted: true }]
}),
expect.objectContaining({
id: 11,
reactionSubjectId: 'PRRC_1',
reactions: [{ content: 'eyes', count: 1, viewerHasReacted: false }]
})
])
})
it('adds and removes PR comment reactions through GraphQL', async () => {
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValue({ stdout: '{}', stderr: '' })
await expect(setPRCommentReaction('/repo-root', 'IC_1', '+1', true)).resolves.toBe(true)
await expect(setPRCommentReaction('/repo-root', 'PRRC_1', 'eyes', false)).resolves.toBe(true)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
expect.arrayContaining(['subjectId=IC_1', 'content=THUMBS_UP']),
expect.any(Object)
)
expect(ghExecFileAsyncMock.mock.calls[0]?.[0].join(' ')).toContain('addReaction')
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
expect.arrayContaining(['subjectId=PRRC_1', 'content=EYES']),
expect.any(Object)
)
expect(ghExecFileAsyncMock.mock.calls[1]?.[0].join(' ')).toContain('removeReaction')
expect(noteRateLimitSpendMock).toHaveBeenCalledTimes(2)
})
it('uses explicit PR repo for merge and title mutations', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({
+109 -2
View File
@@ -12,6 +12,7 @@ import type {
PRCheckDetail,
PRCheckRunDetails,
GitHubCommentResult,
GitHubReactionContent,
GitHubPRReviewCommentInput,
PRComment,
GitHubViewer,
@@ -110,7 +111,11 @@ import {
mapPRState,
deriveCheckStatus
} from './mappers'
import { mapGraphQLReactionGroups, type GitHubGraphQLReactionGroup } from './comment-reactions'
import {
mapGraphQLReactionGroups,
toGraphQLReactionContent,
type GitHubGraphQLReactionGroup
} from './comment-reactions'
import {
getRateLimit,
noteRepositoryRateLimitSpend,
@@ -4117,6 +4122,7 @@ query($owner: String!, $repo: String!, $pr: Int!) {
originalStartLine
comments(first: 100) {
nodes {
id
databaseId
author { __typename login avatarUrl(size: 48) }
body
@@ -4125,6 +4131,7 @@ query($owner: String!, $repo: String!, $pr: Int!) {
path
reactionGroups {
content
viewerHasReacted
reactors {
totalCount
}
@@ -4135,6 +4142,7 @@ query($owner: String!, $repo: String!, $pr: Int!) {
}
comments(first: 100) {
nodes {
id
databaseId
author { __typename login avatarUrl(size: 48) }
body
@@ -4142,6 +4150,24 @@ query($owner: String!, $repo: String!, $pr: Int!) {
url
reactionGroups {
content
viewerHasReacted
reactors {
totalCount
}
}
}
}
reviews(first: 100) {
nodes {
id
databaseId
author { __typename login avatarUrl(size: 48) }
body
createdAt
url
reactionGroups {
content
viewerHasReacted
reactors {
totalCount
}
@@ -4255,6 +4281,7 @@ export async function getPRComments(
originalStartLine: number | null
comments: {
nodes: {
id: string
databaseId: number
author: { __typename?: string; login: string; avatarUrl: string } | null
body: string
@@ -4266,6 +4293,7 @@ export async function getPRComments(
}
}
type GQLIssueComment = {
id: string
databaseId: number
author: { __typename?: string; login: string; avatarUrl: string } | null
body: string
@@ -4273,6 +4301,7 @@ export async function getPRComments(
url: string
reactionGroups?: GitHubGraphQLReactionGroup[] | null
}
let graphQLReviewSummaries: PRComment[] | undefined
const reviewComments: PRComment[] = []
if (threadsResult.status === 'fulfilled' && threadsResult.value) {
const threadsData = JSON.parse(threadsResult.value.stdout) as {
@@ -4281,6 +4310,7 @@ export async function getPRComments(
pullRequest: {
reviewThreads: { nodes: GQLThread[] }
comments?: { nodes: GQLIssueComment[] }
reviews?: { nodes: GQLIssueComment[] }
}
}
}
@@ -4295,12 +4325,28 @@ export async function getPRComments(
createdAt: c.createdAt,
url: c.url,
isBot: c.author?.__typename === 'Bot',
reactionSubjectId: c.id,
reactions: mapGraphQLReactionGroups(c.reactionGroups)
})
)
if (graphQLIssueComments.length > 0) {
issueComments = graphQLIssueComments
}
graphQLReviewSummaries = (pullRequest.reviews?.nodes ?? [])
.filter((review) => review.body?.trim())
.map(
(review): PRComment => ({
id: review.databaseId,
author: review.author?.login ?? 'ghost',
authorAvatarUrl: review.author?.avatarUrl ?? '',
body: review.body,
createdAt: review.createdAt,
url: review.url,
isBot: review.author?.__typename === 'Bot',
reactionSubjectId: review.id,
reactions: mapGraphQLReactionGroups(review.reactionGroups)
})
)
const threads = pullRequest.reviewThreads.nodes
for (const thread of threads) {
@@ -4313,6 +4359,7 @@ export async function getPRComments(
createdAt: c.createdAt,
url: c.url,
isBot: c.author?.__typename === 'Bot',
reactionSubjectId: c.id,
reactions: mapGraphQLReactionGroups(c.reactionGroups),
path: c.path,
threadId: thread.id,
@@ -4340,7 +4387,9 @@ export async function getPRComments(
html_url: string
}
let reviewSummaries: PRComment[] = []
if (reviewsResult.status === 'fulfilled') {
if (graphQLReviewSummaries) {
reviewSummaries = graphQLReviewSummaries
} else if (reviewsResult.status === 'fulfilled') {
reviewSummaries = (JSON.parse(reviewsResult.value.stdout) as RESTReview[])
.filter((r) => r.body?.trim())
.map(
@@ -4393,6 +4442,62 @@ export async function getPRComments(
}
}
export async function setPRCommentReaction(
repoPath: string,
reactionSubjectId: string,
content: GitHubReactionContent,
reacted: boolean,
connectionId?: string | null,
prRepo?: GitHubApiRepository | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<boolean> {
const mutation = reacted ? 'addReaction' : 'removeReaction'
const query = `mutation($subjectId: ID!, $content: ReactionContent!) {
${mutation}(input: { subjectId: $subjectId, content: $content }) {
subject { id }
}
}`
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
prRepo,
connectionId,
localGitOptions
)
if (!ownerRepo) {
return false
}
const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions)
if (guard.blocked) {
console.warn(
`${mutation} skipped: GitHub GraphQL rate limit nearly exhausted (${guard.remaining}/${guard.limit})`
)
return false
}
await acquire()
try {
noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
await ghExecFileAsync(
[
'api',
'graphql',
'-f',
`query=${query}`,
'-f',
`subjectId=${reactionSubjectId}`,
'-f',
`content=${toGraphQLReactionContent(content)}`
],
ghOptions
)
return true
} catch (err) {
console.warn(`${mutation} failed:`, err)
return false
} finally {
release()
}
}
/**
* Mark or unmark a PR file as viewed via GitHub's GraphQL API.
*/
@@ -4492,6 +4597,7 @@ export async function resolveReviewThread(
function mapReviewCommentResponse(
data: {
id?: number
node_id?: string | null
user: { login: string; avatar_url: string; type?: string } | null
body?: string
created_at?: string
@@ -4507,6 +4613,7 @@ function mapReviewCommentResponse(
): PRComment {
return {
id: data.id ?? Date.now(),
reactionSubjectId: data.node_id?.trim() || undefined,
author: data.user?.login ?? 'You',
authorAvatarUrl: data.user?.avatar_url ?? '',
body: data.body ?? body,
+10 -5
View File
@@ -1,19 +1,19 @@
import { describe, expect, it } from 'vitest'
import { mapGraphQLReactionGroups } from './comment-reactions'
import { mapGraphQLReactionGroups, toGraphQLReactionContent } from './comment-reactions'
describe('mapGraphQLReactionGroups', () => {
it('normalizes GraphQL reaction groups into GitHub comment reactions', () => {
expect(
mapGraphQLReactionGroups([
{ content: 'EYES', reactors: { totalCount: 1 } },
{ content: 'THUMBS_UP', reactors: { totalCount: 2 } },
{ content: 'EYES', reactors: { totalCount: 1 }, viewerHasReacted: false },
{ content: 'THUMBS_UP', reactors: { totalCount: 2 }, viewerHasReacted: true },
{ content: 'HEART', reactors: { totalCount: 0 } },
{ content: 'UNKNOWN', reactors: { totalCount: 9 } }
])
).toEqual([
{ content: '+1', count: 2 },
{ content: 'eyes', count: 1 }
{ content: '+1', count: 2, viewerHasReacted: true },
{ content: 'eyes', count: 1, viewerHasReacted: false }
])
})
@@ -22,4 +22,9 @@ describe('mapGraphQLReactionGroups', () => {
undefined
)
})
it('maps REST reaction content to the GraphQL enum', () => {
expect(toGraphQLReactionContent('+1')).toBe('THUMBS_UP')
expect(toGraphQLReactionContent('hooray')).toBe('HOORAY')
})
})
+30 -4
View File
@@ -13,6 +13,7 @@ type GitHubGraphQLReactionContent =
export type GitHubGraphQLReactionGroup = {
content?: string | null
reactors?: { totalCount?: number | null } | null
viewerHasReacted?: boolean | null
}
const GRAPHQL_REACTION_CONTENT: Record<GitHubGraphQLReactionContent, GitHubReactionContent> = {
@@ -37,10 +38,27 @@ const REACTION_ORDER: GitHubReactionContent[] = [
'eyes'
]
const REACTION_CONTENT_TO_GRAPHQL: Record<GitHubReactionContent, GitHubGraphQLReactionContent> = {
'+1': 'THUMBS_UP',
'-1': 'THUMBS_DOWN',
laugh: 'LAUGH',
confused: 'CONFUSED',
heart: 'HEART',
hooray: 'HOORAY',
rocket: 'ROCKET',
eyes: 'EYES'
}
export function toGraphQLReactionContent(
content: GitHubReactionContent
): GitHubGraphQLReactionContent {
return REACTION_CONTENT_TO_GRAPHQL[content]
}
export function mapGraphQLReactionGroups(
groups?: GitHubGraphQLReactionGroup[] | null
): GitHubReaction[] | undefined {
const counts = new Map<GitHubReactionContent, number>()
const reactionsByContent = new Map<GitHubReactionContent, GitHubReaction>()
for (const group of groups ?? []) {
const content =
group.content && group.content in GRAPHQL_REACTION_CONTENT
@@ -50,12 +68,20 @@ export function mapGraphQLReactionGroups(
if (!content || count <= 0) {
continue
}
counts.set(content, (counts.get(content) ?? 0) + count)
const existing = reactionsByContent.get(content)
const viewerHasReacted = Boolean(existing?.viewerHasReacted || group.viewerHasReacted)
reactionsByContent.set(content, {
content,
count: (existing?.count ?? 0) + count,
...(existing?.viewerHasReacted !== undefined || group.viewerHasReacted != null
? { viewerHasReacted }
: {})
})
}
const reactions = REACTION_ORDER.flatMap((content) => {
const count = counts.get(content) ?? 0
return count > 0 ? [{ content, count }] : []
const reaction = reactionsByContent.get(content)
return reaction ? [reaction] : []
})
return reactions.length > 0 ? reactions : undefined
}
+5 -1
View File
@@ -195,6 +195,7 @@ describe('issue source operations', () => {
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
id: 9,
node_id: 'IC_enterprise_9',
user: { login: 'octo', avatar_url: '', type: 'User' },
body: 'Enterprise comment',
created_at: '2026-07-16T00:00:00.000Z',
@@ -208,7 +209,10 @@ describe('issue source operations', () => {
repo: 'orca',
host: 'github.acme-corp.com'
})
).resolves.toMatchObject({ ok: true })
).resolves.toMatchObject({
ok: true,
comment: { reactionSubjectId: 'IC_enterprise_9' }
})
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
[
+2
View File
@@ -473,6 +473,7 @@ export async function addIssueComment(
)
const data = JSON.parse(stdout) as {
id?: number
node_id?: string | null
user: { login: string; avatar_url: string; type?: string } | null
body?: string
created_at?: string
@@ -483,6 +484,7 @@ export async function addIssueComment(
}
const comment: PRComment = {
id: data.id,
reactionSubjectId: data.node_id?.trim() || undefined,
author: data.user?.login ?? 'You',
authorAvatarUrl: data.user?.avatar_url ?? '',
body: data.body ?? body,
+7 -1
View File
@@ -267,6 +267,7 @@ import { AutomationService } from './automations/service'
import { createHeadlessAutomationOutputSnapshotBuffer } from './automations/headless-dispatch'
import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headless-workspace-create'
import { AgentAwakeService } from './agent-awake-service'
import { normalizeComputerAwakeMode } from '../shared/computer-awake-mode'
import { registerSystemResumeBroadcast } from './system-resume-broadcast'
import { settleTeardownWithinDeadline } from './quit-teardown-deadline'
import { quitTeardownStartGate } from './quit-teardown-start-gate'
@@ -2216,7 +2217,12 @@ void app.whenReady().then(async () => {
})
unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast()
agentAwakeService = new AgentAwakeService()
agentAwakeService.setEnabled(store.getSettings().keepComputerAwakeWhileAgentsRun)
agentAwakeService.setMode(
normalizeComputerAwakeMode(
store.getSettings().computerAwakeMode,
store.getSettings().keepComputerAwakeWhileAgentsRun
)
)
// Why: start from empty — disk-hydrated status rows are UI continuity only; only this runtime's hook events keep the computer awake.
agentAwakeService.setStatuses([])
const collectChangedProviderSessionWorktrees = createHookProviderSessionInvalidator()
+21
View File
@@ -22,6 +22,7 @@ const {
getPRChecksMock,
getPRCheckDetailsMock,
getPRCommentsMock,
setPRCommentReactionMock,
resolveReviewThreadMock,
setPRFileViewedMock,
addPRReviewCommentMock,
@@ -64,6 +65,7 @@ const {
getPRChecksMock: vi.fn(),
getPRCheckDetailsMock: vi.fn(),
getPRCommentsMock: vi.fn(),
setPRCommentReactionMock: vi.fn(),
resolveReviewThreadMock: vi.fn(),
setPRFileViewedMock: vi.fn(),
addPRReviewCommentMock: vi.fn(),
@@ -123,6 +125,7 @@ vi.mock('../github/client', () => ({
getPRChecks: getPRChecksMock,
getPRCheckDetails: getPRCheckDetailsMock,
getPRComments: getPRCommentsMock,
setPRCommentReaction: setPRCommentReactionMock,
resolveReviewThread: resolveReviewThreadMock,
setPRFileViewed: setPRFileViewedMock,
addPRReviewComment: addPRReviewCommentMock,
@@ -212,6 +215,7 @@ describe('registerGitHubHandlers', () => {
getPRChecksMock.mockReset()
getPRCheckDetailsMock.mockReset()
getPRCommentsMock.mockReset()
setPRCommentReactionMock.mockReset()
resolveReviewThreadMock.mockReset()
setPRFileViewedMock.mockReset()
addPRReviewCommentMock.mockReset()
@@ -907,6 +911,7 @@ describe('registerGitHubHandlers', () => {
getPRChecksMock.mockResolvedValue([])
getPRCheckDetailsMock.mockResolvedValue(null)
getPRCommentsMock.mockResolvedValue([])
setPRCommentReactionMock.mockResolvedValue(true)
resolveReviewThreadMock.mockResolvedValue(true)
setPRFileViewedMock.mockResolvedValue(true)
addPRReviewCommentReplyMock.mockResolvedValue({ ok: true })
@@ -964,6 +969,13 @@ describe('registerGitHubHandlers', () => {
prRepo,
noCache: true
})
await handlers['gh:setPRCommentReaction'](null, {
repoPath: '/workspace/repo',
reactionSubjectId: ' IC_1 ',
content: '+1',
reacted: true,
prRepo
})
await handlers['gh:resolveReviewThread'](null, {
repoPath: '/workspace/repo',
threadId: 'thread-1',
@@ -1124,6 +1136,15 @@ describe('registerGitHubHandlers', () => {
null,
localGitOptions
)
expect(setPRCommentReactionMock).toHaveBeenCalledWith(
'/workspace/repo',
'IC_1',
'+1',
true,
null,
prRepo,
localGitOptions
)
expect(resolveReviewThreadMock).toHaveBeenCalledWith(
'/workspace/repo',
'thread-1',
+32
View File
@@ -13,6 +13,7 @@ import type {
GitHubPRRefreshCandidate,
GitHubPRRefreshEnqueueResult,
GitHubPRRefreshReason,
GitHubReactionContent,
PRRefreshOutcome,
GitHubPRFile
} from '../../shared/types'
@@ -39,6 +40,7 @@ import {
getPRChecks,
getPRCheckDetails,
getPRComments,
setPRCommentReaction,
resolveReviewThread,
setPRFileViewed,
addPRReviewComment,
@@ -667,6 +669,36 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
}
)
ipcMain.handle(
'gh:setPRCommentReaction',
(
_event,
args: {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
reactionSubjectId: string
content: GitHubReactionContent
reacted: boolean
prRepo?: GitHubOwnerRepo | null
}
) => {
const repo = assertRegisteredRepo(args, store)
if (!args.reactionSubjectId?.trim()) {
return false
}
return setPRCommentReaction(
repo.path,
args.reactionSubjectId.trim(),
args.content,
args.reacted,
repoConnectionId(repo),
args.prRepo ?? null,
...localGitOptionArgs(store, repo)
)
}
)
ipcMain.handle(
'gh:resolveReviewThread',
async (
+311 -1
View File
@@ -16,6 +16,7 @@ import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-a
import { AGENT_SESSION_CLAIM_DIGEST_VERSION } from '../../shared/agent-session-host-authority'
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors'
import type * as Wsl from '../wsl'
const isWindowsHost = process.platform === 'win32'
const posixOnlyIt = isWindowsHost ? it.skip : it
@@ -55,8 +56,10 @@ const {
clearAgentHookPaneStateMock,
registerPaneKeyAliasMock,
piBuildPtyEnvMock,
piBuildStatusOnlyPtyEnvMock,
piClearPtyMock,
isPwshAvailableMock,
wslUncDirectoryExistsAsyncMock,
trackMock,
classifyErrorMock,
registerPtyMock,
@@ -86,11 +89,13 @@ const {
openCodeBuildPtyEnvMock: vi.fn(),
mimoCodeBuildPtyEnvMock: vi.fn(),
isPwshAvailableMock: vi.fn(),
wslUncDirectoryExistsAsyncMock: vi.fn(),
openCodeClearPtyMock: vi.fn(),
buildAgentHookEnvMock: vi.fn(),
clearAgentHookPaneStateMock: vi.fn(),
registerPaneKeyAliasMock: vi.fn(),
piBuildPtyEnvMock: vi.fn(),
piBuildStatusOnlyPtyEnvMock: vi.fn(),
piClearPtyMock: vi.fn(),
trackMock: vi.fn(),
classifyErrorMock: vi.fn(),
@@ -175,6 +180,7 @@ vi.mock('../agent-hooks/server', () => ({
vi.mock('../pi/titlebar-extension-service', () => ({
piTitlebarExtensionService: {
buildPtyEnv: piBuildPtyEnvMock,
buildStatusOnlyPtyEnv: piBuildStatusOnlyPtyEnvMock,
clearPty: piClearPtyMock
}
}))
@@ -183,6 +189,11 @@ vi.mock('../pwsh', () => ({
isPwshAvailableAsync: isPwshAvailableMock
}))
vi.mock('../wsl', async (importOriginal) => ({
...(await importOriginal<typeof Wsl>()),
wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args)
}))
vi.mock('../telemetry/client', () => ({
track: trackMock
}))
@@ -333,6 +344,7 @@ describe('registerPtyHandlers', () => {
const savedOrcaOmpStatusExtension = process.env.ORCA_OMP_STATUS_EXTENSION
const savedPrimeAgentDir = process.env.PRIME_AGENT_CODING_AGENT_DIR
const savedOrcaPrimeAgentSourceDir = process.env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR
const savedOrcaPrimeAgentStatusExtension = process.env.ORCA_PRIME_AGENT_STATUS_EXTENSION
const savedOrcaClaudeAgentStatusSettings = process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS
const savedProcessPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
const savedDisableMacosLoginShell = process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
@@ -360,6 +372,7 @@ describe('registerPtyHandlers', () => {
delete process.env.ORCA_OMP_STATUS_EXTENSION
delete process.env.PRIME_AGENT_CODING_AGENT_DIR
delete process.env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR
delete process.env.ORCA_PRIME_AGENT_STATUS_EXTENSION
handlers.clear()
handleMock.mockReset()
onMock.mockReset()
@@ -382,8 +395,11 @@ describe('registerPtyHandlers', () => {
clearAgentHookPaneStateMock.mockReset()
registerPaneKeyAliasMock.mockReset()
piBuildPtyEnvMock.mockReset()
piBuildStatusOnlyPtyEnvMock.mockReset()
piClearPtyMock.mockReset()
isPwshAvailableMock.mockReset()
wslUncDirectoryExistsAsyncMock.mockReset()
wslUncDirectoryExistsAsyncMock.mockResolvedValue(null)
trackMock.mockReset()
classifyErrorMock.mockReset()
registerPtyMock.mockReset()
@@ -481,6 +497,10 @@ describe('registerPtyHandlers', () => {
}
}
)
piBuildStatusOnlyPtyEnvMock.mockReturnValue({
ORCA_PRIME_AGENT_STATUS_EXTENSION:
'C:\\Users\\test\\AppData\\Roaming\\Orca\\prime-agent-managed-status-extension\\orca-agent-status.ts'
})
isPwshAvailableMock.mockReturnValue(false)
spawnMock.mockReturnValue({
onData: vi.fn(() => makeDisposable()),
@@ -586,6 +606,11 @@ describe('registerPtyHandlers', () => {
} else {
delete process.env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR
}
if (savedOrcaPrimeAgentStatusExtension !== undefined) {
process.env.ORCA_PRIME_AGENT_STATUS_EXTENSION = savedOrcaPrimeAgentStatusExtension
} else {
delete process.env.ORCA_PRIME_AGENT_STATUS_EXTENSION
}
if (savedOrcaClaudeAgentStatusSettings === undefined) {
delete process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS
} else {
@@ -4196,7 +4221,7 @@ describe('registerPtyHandlers', () => {
})
})
it('does not materialize Windows-host Prime extensions for a WSL launch', async () => {
it('bridges a host-managed Prime status extension into WSL without redirecting Prime home', async () => {
await withWin32Platform(async () => {
const env = await daemonSpawnAndGetEnv(
{ PRIME_AGENT_CODING_AGENT_DIR: 'C:\\Users\\test\\.prime\\agent' },
@@ -4207,11 +4232,30 @@ describe('registerPtyHandlers', () => {
)
expect(piBuildPtyEnvMock).not.toHaveBeenCalled()
expect(piBuildStatusOnlyPtyEnvMock).toHaveBeenCalledWith('prime-agent')
expect(env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PRIME_AGENT_STATUS_EXTENSION).toBe(
'C:\\Users\\test\\AppData\\Roaming\\Orca\\prime-agent-managed-status-extension\\orca-agent-status.ts'
)
expect(env.ORCA_WSL_HOOK_INSTANCE).toBe('port5678')
expect(env.PRIME_AGENT_CODING_AGENT_DIR).toBe('C:\\Users\\test\\.prime\\agent')
})
})
it('prepares the Prime bridge for a typed launch in a bare WSL shell', async () => {
await withWin32Platform(async () => {
const env = await daemonSpawnAndGetEnv({}, undefined, undefined, undefined, {
shellOverride: 'wsl.exe'
})
expect(piBuildStatusOnlyPtyEnvMock).toHaveBeenCalledWith('prime-agent')
expect(env.ORCA_PRIME_AGENT_STATUS_EXTENSION).toContain(
'prime-agent-managed-status-extension'
)
expect(env.PRIME_AGENT_CODING_AGENT_DIR).toBeUndefined()
})
})
it('points OPENCODE_CONFIG_DIR at the guest overlay when the WSL relay reports it', async () => {
const guestDir = '/home/jin/.orca-relay/opencode-overlays/abc'
const spy = vi.spyOn(wslHookRelayManager, 'getOpenCodeOverlayDir').mockReturnValue(guestDir)
@@ -12125,6 +12169,272 @@ describe('registerPtyHandlers', () => {
expect(result.startupCwdFallback).toEqual({ kind: 'worktree', cwd: worktreePath })
})
it('keeps an existing POSIX startup cwd for the selected WSL runtime', async () => {
const originalPlatform = process.platform
const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-cwd' })
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
wslUncDirectoryExistsAsyncMock.mockResolvedValue(true)
statSyncMock.mockImplementation((target: string) => {
if (target === '/home/alice/repo') {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
}
return { isDirectory: () => true, mode: 0o755 }
})
try {
installDaemonTestProvider({ spawn: providerSpawn })
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/home/alice/repo',
cwdFallback: 'worktree',
worktreeId: 'repo-1::C:\\Users\\alice\\repo',
projectRuntime: {
status: 'resolved',
runtime: {
kind: 'wsl',
hostPlatform: 'wsl',
projectId: 'repo-1',
distro: 'Ubuntu-24.04',
reason: 'project-override',
cacheKey: 'repo-1:wsl'
}
}
})
expect(statSyncMock).not.toHaveBeenCalledWith('/home/alice/repo')
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith(
'\\\\wsl.localhost\\Ubuntu-24.04\\home\\alice\\repo'
)
expect(providerSpawn).toHaveBeenCalledWith(
expect.objectContaining({ cwd: '/home/alice/repo', shellOverride: 'wsl.exe' })
)
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('falls back when the selected WSL runtime reports a POSIX cwd missing', async () => {
const originalPlatform = process.platform
const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-missing-cwd' })
const worktreePath = 'C:/Users/alice/repo'
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
wslUncDirectoryExistsAsyncMock.mockResolvedValue(false)
try {
installDaemonTestProvider({ spawn: providerSpawn })
registerPtyHandlers(mainWindow as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/home/alice/deleted',
cwdFallback: 'worktree',
worktreeId: `repo-1::${worktreePath}`,
projectRuntime: {
status: 'resolved',
runtime: {
kind: 'wsl',
hostPlatform: 'wsl',
projectId: 'repo-1',
distro: 'Ubuntu-24.04',
reason: 'project-override',
cacheKey: 'repo-1:wsl'
}
}
})) as { startupCwdFallback?: { kind: string; cwd: string } }
expect(providerSpawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: worktreePath }))
expect(result.startupCwdFallback).toEqual({ kind: 'worktree', cwd: worktreePath })
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('preserves a POSIX WSL cwd when the distro probe is inconclusive', async () => {
const originalPlatform = process.platform
const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-inconclusive-cwd' })
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
wslUncDirectoryExistsAsyncMock.mockResolvedValue(null)
try {
installDaemonTestProvider({ spawn: providerSpawn })
registerPtyHandlers(mainWindow as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/home/alice/repo',
cwdFallback: 'worktree',
worktreeId: 'repo-1::C:/Users/alice/repo',
shellOverride: 'wsl.exe'
})) as { startupCwdFallback?: unknown }
expect(providerSpawn).toHaveBeenCalledWith(
expect.objectContaining({ cwd: '/home/alice/repo' })
)
expect(result.startupCwdFallback).toBeUndefined()
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('preserves a POSIX cwd when WSL owns it but no distro can be resolved', async () => {
const originalPlatform = process.platform
const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-no-distro-cwd' })
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
_setWslCachesForTests({ available: true, distros: [] })
try {
installDaemonTestProvider({ spawn: providerSpawn })
registerPtyHandlers(mainWindow as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/home/alice/repo',
cwdFallback: 'worktree',
worktreeId: 'repo-1::C:/Users/alice/repo',
shellOverride: 'wsl.exe'
})) as { startupCwdFallback?: unknown }
expect(statSyncMock).not.toHaveBeenCalledWith('/home/alice/repo')
expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled()
expect(providerSpawn).toHaveBeenCalledWith(
expect.objectContaining({ cwd: '/home/alice/repo' })
)
expect(result.startupCwdFallback).toBeUndefined()
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it.each([
{ exists: true, expectedCwd: '/home/alice/repo', expectedFallback: undefined },
{
exists: false,
expectedCwd: '\\\\wsl.localhost\\Ubuntu\\home\\alice',
expectedFallback: {
kind: 'worktree',
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\alice'
}
}
])('resolves POSIX cwd existence for a WSL UNC workspace ($exists)', async (testCase) => {
const originalPlatform = process.platform
const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-unc-cwd' })
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
wslUncDirectoryExistsAsyncMock.mockResolvedValueOnce(testCase.exists)
if (!testCase.exists) {
wslUncDirectoryExistsAsyncMock.mockResolvedValueOnce(true)
}
try {
installDaemonTestProvider({ spawn: providerSpawn })
registerPtyHandlers(mainWindow as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/home/alice/repo',
cwdFallback: 'worktree',
worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\alice'
})) as { startupCwdFallback?: { kind: string; cwd: string } }
expect(providerSpawn).toHaveBeenCalledWith(
expect.objectContaining({ cwd: testCase.expectedCwd })
)
expect(result.startupCwdFallback).toEqual(testCase.expectedFallback)
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('preserves a missing POSIX cwd when its WSL workspace root is also missing', async () => {
const originalPlatform = process.platform
const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-missing-root' })
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
wslUncDirectoryExistsAsyncMock.mockResolvedValue(false)
try {
installDaemonTestProvider({ spawn: providerSpawn })
registerPtyHandlers(mainWindow as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/home/alice/deleted',
cwdFallback: 'worktree',
worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\alice'
})) as { startupCwdFallback?: unknown }
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledTimes(2)
expect(providerSpawn).toHaveBeenCalledWith(
expect.objectContaining({ cwd: '/home/alice/deleted' })
)
expect(result.startupCwdFallback).toBeUndefined()
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('validates a /mnt drive cwd through its native Windows path', async () => {
const originalPlatform = process.platform
const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-mnt-cwd' })
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
_setWslCachesForTests({ available: true, distros: ['Ubuntu'] })
try {
installDaemonTestProvider({ spawn: providerSpawn })
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/mnt/c/Users/alice/repo',
cwdFallback: 'worktree',
worktreeId: 'repo-1::C:/Users/alice/repo',
shellOverride: 'wsl.exe'
})
expect(statSyncMock).toHaveBeenCalledWith('C:\\Users\\alice\\repo')
expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled()
expect(providerSpawn).toHaveBeenCalledWith(
expect.objectContaining({ cwd: '/mnt/c/Users/alice/repo' })
)
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('still falls back a missing Windows cwd when the selected runtime is WSL', async () => {
const originalPlatform = process.platform
const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-windows-cwd' })
const worktreePath = 'C:/Users/alice/repo'
const missingCwd = `${worktreePath}/deleted-folder`
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
statSyncMock.mockImplementation((target: string) => {
if (target === missingCwd) {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
}
return { isDirectory: () => true, mode: 0o755 }
})
try {
installDaemonTestProvider({ spawn: providerSpawn })
registerPtyHandlers(mainWindow as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: missingCwd,
cwdFallback: 'worktree',
worktreeId: `repo-1::${worktreePath}`,
shellOverride: 'wsl.exe'
})) as { startupCwdFallback?: { kind: string; cwd: string } }
expect(providerSpawn).toHaveBeenCalledWith(
expect.objectContaining({ cwd: worktreePath, shellOverride: 'wsl.exe' })
)
expect(result.startupCwdFallback).toEqual({ kind: 'worktree', cwd: worktreePath })
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('keeps a missing cwd unchanged without the fallback flag', async () => {
registerPtyHandlers(mainWindow as never)
existsSyncMock.mockImplementation((target: string) => target !== '/repo/app/deleted-folder')
+85 -11
View File
@@ -139,7 +139,7 @@ import {
resolveTerminalStartupCwdForWorkspace,
type TerminalStartupCwdMissingDirFallback
} from '../../shared/terminal-startup-cwd'
import { isWslUncPath } from '../../shared/wsl-paths'
import { isWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths'
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority'
import {
@@ -150,7 +150,7 @@ import {
clearMigrationUnsupportedPty,
clearMigrationUnsupportedPtysForPaneKey
} from '../agent-hooks/migration-unsupported-pty-state'
import { parseWslPath } from '../wsl'
import { parseWslPath, wslUncDirectoryExistsAsync } from '../wsl'
import { mergePersistedWindowsPath, resolvePathEnvKey } from '../pty/windows-environment-path'
import { addOrcaWslInteropEnv, stampWslOrchestrationCompatibilityHost } from '../pty/wsl-orca-env'
import { PtyProducerFlowController } from './pty-producer-flow-control'
@@ -1513,6 +1513,7 @@ function clearPiAgentShadowEnv(baseEnv: Record<string, string>, kind: PiAgentKin
}
if (kind === 'prime-agent') {
delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR
delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION
return
}
delete baseEnv.ORCA_PI_CODING_AGENT_DIR
@@ -1544,6 +1545,11 @@ function exposePiManagedExtensionEnv(
} else {
delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR
}
if (managedEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION) {
baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION = managedEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION
} else {
delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION
}
return
}
delete baseEnv.ORCA_PI_CODING_AGENT_DIR
@@ -1686,6 +1692,8 @@ export function buildPtyHostEnv(
})
const shouldPrepareOmpShadow = piAgentKind === 'omp' || !hasLaunchCommand
const shouldPreparePrimeStatus =
piAgentKind === 'prime-agent' || (opts.isWsl && !hasLaunchCommand)
// Why: source shadows are agent-scoped; trusting the other kind's source reintroduces Pi/OMP extension-state shadowing.
const preexistingPiAgentDir = resolvePiAgentSourceDir(baseEnv, 'pi')
const preexistingOmpAgentDir =
@@ -1744,6 +1752,12 @@ export function buildPtyHostEnv(
if (opts.isWsl === true) {
// Why: hook POSTs to 127.0.0.1 die inside WSL's NAT namespace; use the guest-resident relay's endpoint instead of the Windows one.
const distro = opts.wslDistro ?? null
const hookInstance = wslHookRelayManager.getInstanceKey()
if (hookInstance) {
baseEnv.ORCA_WSL_HOOK_INSTANCE = hookInstance
} else {
delete baseEnv.ORCA_WSL_HOOK_INSTANCE
}
wslHookRelayManager.ensureForDistro(distro)
const guestEndpoint = wslHookRelayManager.getGuestEndpointFilePath(distro)
if (guestEndpoint) {
@@ -1790,13 +1804,14 @@ export function buildPtyHostEnv(
exposePiManagedExtensionEnv(baseEnv, 'omp', ompEnv)
}
if (piAgentKind === 'prime-agent' && !opts.isWsl) {
const primeEnv = piTitlebarExtensionService.buildPtyEnv(
id,
preexistingPrimeAgentDir,
'prime-agent',
{ materializeDefaultHome: explicitPiAgentKind === 'prime-agent' }
)
if (shouldPreparePrimeStatus) {
// Why: the Windows host cannot safely resolve or write Prime's guest
// config root; WSL loads the host-managed status file explicitly.
const primeEnv = opts.isWsl
? piTitlebarExtensionService.buildStatusOnlyPtyEnv('prime-agent')
: piTitlebarExtensionService.buildPtyEnv(id, preexistingPrimeAgentDir, 'prime-agent', {
materializeDefaultHome: explicitPiAgentKind === 'prime-agent'
})
Object.assign(baseEnv, primeEnv)
exposePiManagedExtensionEnv(baseEnv, 'prime-agent', primeEnv)
}
@@ -1814,6 +1829,7 @@ export function buildPtyHostEnv(
})
delete baseEnv.ORCA_OMP_STATUS_EXTENSION
delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR
delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION
}
// Why: keep the Codex home override PTY-scoped so dev/prod Orcas don't share hooks through ~/.codex.
@@ -5775,15 +5791,73 @@ export function registerPtyHandlers(
await startupPromise
}
// Why: honor the fallback only for fresh local spawns — reattach needs exact cwd and SSH can't probe the local filesystem.
const allowMissingCwdFallback =
const requestedMissingCwdFallback =
!args.connectionId && !args.sessionId && args.cwdFallback === 'worktree'
const isWslOwnedPosixCwd =
args.cwd?.startsWith('/') === true && !/^\/[A-Za-z](?:\/|$)/.test(args.cwd)
const startupWorkspaceCwd =
requestedMissingCwdFallback && isWslOwnedPosixCwd
? resolvePtySpawnStartupCwd(args.worktreeId, '.')
: undefined
const initiallyResolvedStartupCwd =
requestedMissingCwdFallback && isWslOwnedPosixCwd
? resolvePtySpawnStartupCwd(args.worktreeId, args.cwd)
: undefined
const startupTerminalRuntimeOptions =
requestedMissingCwdFallback && process.platform === 'win32'
? resolveLocalWindowsTerminalRuntimeOptions({
requestedShellOverride: args.shellOverride,
settings: getSettings?.(),
projectRuntime: args.projectRuntime,
fallbackHostShell: process.env.COMSPEC || 'powershell.exe'
})
: undefined
const wslRuntimeOwnsStartupCwd =
requestedMissingCwdFallback &&
isWslOwnedPosixCwd &&
(isWslShellName(startupTerminalRuntimeOptions?.shellOverride) ||
isWslUncPath(startupWorkspaceCwd ?? ''))
const startupWslContext = wslRuntimeOwnsStartupCwd
? resolveWslSessionContext({
cwd: startupWorkspaceCwd,
shellOverride: startupTerminalRuntimeOptions?.shellOverride,
terminalWindowsWslDistro: startupTerminalRuntimeOptions?.terminalWindowsWslDistro
})
: undefined
let wslStartupCwdExists: boolean | null = null
let wslWorkspaceCwdExists: boolean | null = null
if (startupWslContext && initiallyResolvedStartupCwd) {
const validationCwd = toWindowsWslPath(
initiallyResolvedStartupCwd,
startupWslContext.distro
)
wslStartupCwdExists = isWslUncPath(validationCwd)
? await wslUncDirectoryExistsAsync(validationCwd)
: localStartupCwdDirectoryExists(validationCwd)
if (wslStartupCwdExists === false && startupWorkspaceCwd) {
wslWorkspaceCwdExists = isWslUncPath(startupWorkspaceCwd)
? await wslUncDirectoryExistsAsync(startupWorkspaceCwd)
: localStartupCwdDirectoryExists(startupWorkspaceCwd)
}
}
const allowMissingCwdFallback =
requestedMissingCwdFallback &&
(!wslRuntimeOwnsStartupCwd ||
(wslStartupCwdExists === false && wslWorkspaceCwdExists === true))
let didFallbackToWorkspaceRootCwd = false
const cwd = resolvePtySpawnStartupCwd(
args.worktreeId,
args.cwd,
allowMissingCwdFallback
? {
directoryExists: localStartupCwdDirectoryExists,
directoryExists: (path) =>
startupWslContext &&
wslStartupCwdExists === false &&
path === initiallyResolvedStartupCwd
? false
: startupWslContext && path === startupWorkspaceCwd
? wslWorkspaceCwdExists === true
: localStartupCwdDirectoryExists(path),
onFallbackToWorkspaceRoot: () => {
didFallbackToWorkspaceRootCwd = true
}
+11 -4
View File
@@ -344,7 +344,7 @@ describe('registerSettingsHandlers', () => {
})
it('updates the agent awake service when the keep-awake setting changes', () => {
const agentAwakeService = { setEnabled: vi.fn() }
const agentAwakeService = { setMode: vi.fn() }
store.getSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: false })
store.updateSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: true })
registerSettingsHandlers(store as never, agentAwakeService as never)
@@ -356,11 +356,18 @@ describe('registerSettingsHandlers', () => {
handler(settingsInvokeEvent, { keepComputerAwakeWhileAgentsRun: true })
expect(agentAwakeService.setEnabled).toHaveBeenCalledWith(true)
expect(store.updateSettings).toHaveBeenCalledWith(
{
computerAwakeMode: 'auto',
keepComputerAwakeWhileAgentsRun: true
},
expect.any(Object)
)
expect(agentAwakeService.setMode).toHaveBeenCalledWith('auto')
})
it('does not notify the agent awake service for unrelated setting changes', () => {
const agentAwakeService = { setEnabled: vi.fn() }
const agentAwakeService = { setMode: vi.fn() }
store.getSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: false })
store.updateSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: false })
registerSettingsHandlers(store as never, agentAwakeService as never)
@@ -372,7 +379,7 @@ describe('registerSettingsHandlers', () => {
handler(settingsInvokeEvent, { defaultTuiAgent: 'codex' })
expect(agentAwakeService.setEnabled).not.toHaveBeenCalled()
expect(agentAwakeService.setMode).not.toHaveBeenCalled()
})
it('prepares local worktree roots when workspace directory changes', async () => {
+39 -2
View File
@@ -29,6 +29,10 @@ import {
normalizeMobilePairingCustomAddress,
normalizeMobilePairingCustomAddresses
} from '../../shared/mobile-pairing-custom-address'
import {
computerAwakeSettingsForMode,
normalizeComputerAwakeMode
} from '../../shared/computer-awake-mode'
// Why: the whitelist is the source-of-truth for which keys we emit on. Casting
// to a Set once at module load lets the IPC handler's per-key membership
@@ -65,6 +69,18 @@ export function registerSettingsHandlers(
store: Store,
agentAwakeService?: AgentAwakeService
): void {
ipcMain.handle(
'agentAwake:getStatus',
() => agentAwakeService?.getStatus() ?? { mode: 'off', active: false }
)
agentAwakeService?.subscribe?.((status) => {
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) {
window.webContents.send('agentAwake:changed', status)
}
}
})
store.onSettingsChanged((updates, _settings, originWebContentsId) => {
for (const window of BrowserWindow.getAllWindows()) {
const isOrigin =
@@ -109,6 +125,22 @@ export function registerSettingsHandlers(
// Why: Floating Workspace grants are trusted only when written by the
// main-process directory picker, never by renderer-provided settings IPC.
delete sanitizedArgs.floatingTerminalTrustedCwds
if ('computerAwakeMode' in sanitizedArgs) {
Object.assign(
sanitizedArgs,
computerAwakeSettingsForMode(
normalizeComputerAwakeMode(
sanitizedArgs.computerAwakeMode,
sanitizedArgs.keepComputerAwakeWhileAgentsRun
)
)
)
} else if ('keepComputerAwakeWhileAgentsRun' in sanitizedArgs) {
Object.assign(
sanitizedArgs,
computerAwakeSettingsForMode(sanitizedArgs.keepComputerAwakeWhileAgentsRun ? 'auto' : 'off')
)
}
if (typeof args.floatingTerminalCwd === 'string') {
sanitizedArgs.floatingTerminalCwd = await sanitizeFloatingWorkspaceDirectorySetting(
store,
@@ -161,8 +193,13 @@ export function registerSettingsHandlers(
notifyListeners: true,
originWebContentsId: event.sender.id
})
if ('keepComputerAwakeWhileAgentsRun' in sanitizedArgs) {
agentAwakeService?.setEnabled(result.keepComputerAwakeWhileAgentsRun)
if (
'computerAwakeMode' in sanitizedArgs ||
'keepComputerAwakeWhileAgentsRun' in sanitizedArgs
) {
agentAwakeService?.setMode(
normalizeComputerAwakeMode(result.computerAwakeMode, result.keepComputerAwakeWhileAgentsRun)
)
}
const hookSettingChanged =
('agentStatusHooksEnabled' in sanitizedArgs &&
@@ -1,10 +1,12 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { writeFileSync } from 'node:fs'
import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
createWorktreePollerWindowVisibility,
startWorktreeBaseDirectoryPoller,
WORKTREE_BASE_BACKSTOP_TICKS,
type WorktreeBasePollEvent,
type WorktreePollerWindowVisibility
} from './worktree-base-directory-poller'
@@ -204,6 +206,65 @@ describe('worktree base directory poller', () => {
expect(fullScans.length).toBeGreaterThan(0)
})
it('retires stale marker probes while preserving backstop detection and path reuse', async () => {
const root = await makeRoot()
const ordinaryFolder = join(root, 'ordinary-folder')
const markerPath = join(ordinaryFolder, '.git')
await mkdir(ordinaryFolder)
const received: WorktreeBasePollEvent[][] = []
const target = makeTarget('base', root)
let fullScans = 0
let pendingMarkerProbes = 0
let writeMarkerOnNextProbe = false
const pendingMarkerMaxTicks = WORKTREE_BASE_BACKSTOP_TICKS * 2
const markerCreationTick = pendingMarkerMaxTicks * 2 + WORKTREE_BASE_BACKSTOP_TICKS * 4
const poller = await startWorktreeBaseDirectoryPoller(
target,
() => target.repos,
(events) => received.push(events),
{
pollIntervalMs: 0,
pendingMarkerMaxTicks,
onFullScan: () => {
fullScans += 1
if (fullScans * WORKTREE_BASE_BACKSTOP_TICKS === markerCreationTick) {
writeFileSync(markerPath, 'gitdir: elsewhere')
}
},
onPendingMarkerProbe: (path) => {
pendingMarkerProbes += 1
if (writeMarkerOnNextProbe && path === markerPath) {
writeMarkerOnNextProbe = false
writeFileSync(markerPath, 'gitdir: elsewhere')
}
}
}
)
cleanups.push(() => poller.unsubscribe())
await waitForEvents(received, (flat) =>
flat.some((event) => event.type === 'create' && event.path === markerPath)
)
await rm(ordinaryFolder, { recursive: true })
await waitForEvents(received, (flat) =>
flat.some((event) => event.type === 'delete' && event.path === ordinaryFolder)
)
writeMarkerOnNextProbe = true
await mkdir(ordinaryFolder)
const events = await waitForEvents(
received,
(flat) =>
flat.filter((event) => event.type === 'create' && event.path === markerPath).length === 2
)
expect(pendingMarkerProbes).toBeLessThanOrEqual(pendingMarkerMaxTicks)
expect(
events.filter((event) => event.type === 'create' && event.path === markerPath)
).toHaveLength(2)
})
it('parks base scans while hidden and losslessly detects changes on resume', async () => {
const root = await makeRoot()
const visibility = createVisibilityHarness()
+28 -15
View File
@@ -63,6 +63,10 @@ export type WorktreeBasePollerOptions = {
onWatchError?: (error: Error) => void
/** Test hook: called whenever a full snapshot scan runs (vs. a gated skip). */
onFullScan?: () => void
/** Test hook: called before a pending `.git` marker stat. */
onPendingMarkerProbe?: (path: string) => void
/** Test hook: overrides the fast-probe window. */
pendingMarkerMaxTicks?: number
}
// Why: these targets used to be recursive FSEvents subscriptions spanning the
@@ -193,7 +197,7 @@ async function startBasePoller(
onEvents: (events: WorktreeBasePollEvent[]) => void,
pollIntervalMs: number,
visibility: WorktreePollerWindowVisibility,
onFullScan?: () => void
options: WorktreeBasePollerOptions
): Promise<WorktreeBaseSubscription> {
let disposed = false
let ticking = false
@@ -202,16 +206,17 @@ async function startBasePoller(
let gateSignatures = await Promise.all(snapshot.gateDirs.map(dirSignature))
let timer: ReturnType<typeof setTimeout> | null = null
let parkedWhileHidden = false
// dir → tick when first seen without a `.git` marker
const pendingMarkers = new Map<string, number>()
const pendingMarkerMaxTicks = options.pendingMarkerMaxTicks ?? PENDING_MARKER_MAX_TICKS
// dir → first probe tick; null means backstop scans only
const markerProbeStartedAt = new Map<string, number | null>()
for (const [dir, marker] of snapshot.markers) {
if (!marker) {
pendingMarkers.set(dir, 0)
markerProbeStartedAt.set(dir, 0)
}
}
const fullScan = async (): Promise<void> => {
onFullScan?.()
options.onFullScan?.()
const next = await snapshotBase(target.path, getRepos())
const nextSignatures = await Promise.all(next.gateDirs.map(dirSignature))
if (disposed) {
@@ -220,14 +225,14 @@ async function startBasePoller(
const events = diffBase(snapshot, next)
for (const [dir, marker] of next.markers) {
if (marker) {
pendingMarkers.delete(dir)
} else if (!pendingMarkers.has(dir)) {
pendingMarkers.set(dir, tickCount)
markerProbeStartedAt.delete(dir)
} else if (!markerProbeStartedAt.has(dir)) {
markerProbeStartedAt.set(dir, tickCount)
}
}
for (const [dir, firstSeenTick] of pendingMarkers) {
if (!next.markers.has(dir) || tickCount - firstSeenTick > PENDING_MARKER_MAX_TICKS) {
pendingMarkers.delete(dir)
for (const dir of markerProbeStartedAt.keys()) {
if (!next.markers.has(dir)) {
markerProbeStartedAt.delete(dir)
}
}
snapshot = next
@@ -239,9 +244,17 @@ async function startBasePoller(
const checkPendingMarkers = async (): Promise<void> => {
const events: WorktreeBasePollEvent[] = []
for (const dir of pendingMarkers.keys()) {
for (const [dir, firstSeenTick] of markerProbeStartedAt) {
if (firstSeenTick === null) {
continue
}
if (tickCount - firstSeenTick > pendingMarkerMaxTicks) {
markerProbeStartedAt.set(dir, null)
continue
}
options.onPendingMarkerProbe?.(join(dir, '.git'))
if (await hasGitMarker(dir)) {
pendingMarkers.delete(dir)
markerProbeStartedAt.delete(dir)
snapshot.markers.set(dir, true)
events.push({ type: 'create', path: join(dir, '.git') })
}
@@ -267,7 +280,7 @@ async function startBasePoller(
await fullScan()
return
}
if (pendingMarkers.size > 0) {
if (markerProbeStartedAt.size > 0) {
await checkPendingMarkers()
}
}
@@ -356,5 +369,5 @@ export async function startWorktreeBaseDirectoryPoller(
options.onWatchError
)
}
return startBasePoller(target, getRepos, onEvents, pollIntervalMs, visibility, options.onFullScan)
return startBasePoller(target, getRepos, onEvents, pollIntervalMs, visibility, options)
}
+28 -2
View File
@@ -5,10 +5,12 @@ import { credentialDecryptionMessage } from '../../shared/integration-credential
const getClients = vi.fn()
const clearToken = vi.fn()
const isAuthError = vi.fn()
const acquire = vi.fn().mockResolvedValue(undefined)
const release = vi.fn()
vi.mock('./client', () => ({
acquire: vi.fn().mockResolvedValue(undefined),
release: vi.fn(),
acquire,
release,
getClients: (...args: unknown[]) => getClients(...args),
isAuthError: (...args: unknown[]) => isAuthError(...args),
clearToken: (...args: unknown[]) => clearToken(...args)
@@ -283,6 +285,30 @@ describe('Linear teams', () => {
expect(members).toHaveBeenCalledWith({ first: 100 })
})
it.each([
['getTeamStates', 'getTeamStatesOrThrow'],
['getTeamLabels', 'getTeamLabelsOrThrow'],
['getTeamMembers', 'getTeamMembersOrThrow']
] as const)('preserves the %s error policy', async (fallbackReader, throwingReader) => {
const error = new Error('request failed')
const entry = makeTeamLookupEntry('workspace-1', 'Workspace', null)
vi.mocked(entry.client.team).mockRejectedValue(error)
getClients.mockReturnValue([entry])
const readers = await import('./teams')
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
await expect(readers[fallbackReader]('team-1', 'workspace-1')).resolves.toEqual([])
await expect(readers[throwingReader]('team-1', 'workspace-1')).rejects.toBe(error)
expect(warn).toHaveBeenCalledWith(`[linear] ${fallbackReader} failed:`, error)
expect(release).toHaveBeenCalledTimes(2)
isAuthError.mockReturnValue(true)
await expect(readers[fallbackReader]('team-1', 'workspace-1')).rejects.toBe(error)
expect(clearToken).toHaveBeenCalledWith('workspace-1')
expect(release).toHaveBeenCalledTimes(3)
warn.mockRestore()
})
it('surfaces Linear credential decrypt errors on active team reads', async () => {
const error = new Error(credentialDecryptionMessage('Linear'))
getClients.mockImplementation(() => {
+23 -95
View File
@@ -1,3 +1,4 @@
import type { Team } from '@linear/sdk'
import type {
LinearTeam,
LinearWorkflowState,
@@ -108,10 +109,12 @@ export async function listTeamsForAgent(
}
}
export async function getTeamStates(
async function readTeamResource<T>(
teamId: string,
workspaceId?: string | null
): Promise<LinearWorkflowState[]> {
workspaceId: string | null | undefined,
fetchAll: (team: Team) => Promise<T[]>,
fallbackWarning?: string
): Promise<T[]> {
const entry = getClients(workspaceId)[0]
if (!entry) {
return []
@@ -119,137 +122,62 @@ export async function getTeamStates(
await acquire()
try {
const team = await entry.client.team(teamId)
return await fetchAllTeamStates(team)
return await fetchAll(await entry.client.team(teamId))
} catch (error) {
if (isAuthError(error)) {
clearToken(entry.workspace.id)
throw error
}
console.warn('[linear] getTeamStates failed:', error)
if (!fallbackWarning) {
throw error
}
console.warn(`[linear] ${fallbackWarning} failed:`, error)
return []
} finally {
release()
}
}
export async function getTeamStates(
teamId: string,
workspaceId?: string | null
): Promise<LinearWorkflowState[]> {
return readTeamResource(teamId, workspaceId, fetchAllTeamStates, 'getTeamStates')
}
export async function getTeamStatesOrThrow(
teamId: string,
workspaceId?: string | null
): Promise<LinearWorkflowState[]> {
const entry = getClients(workspaceId)[0]
if (!entry) {
return []
}
await acquire()
try {
const team = await entry.client.team(teamId)
return await fetchAllTeamStates(team)
} catch (error) {
if (isAuthError(error)) {
clearToken(entry.workspace.id)
}
throw error
} finally {
release()
}
return readTeamResource(teamId, workspaceId, fetchAllTeamStates)
}
export async function getTeamLabels(
teamId: string,
workspaceId?: string | null
): Promise<LinearLabel[]> {
const entry = getClients(workspaceId)[0]
if (!entry) {
return []
}
await acquire()
try {
const team = await entry.client.team(teamId)
return await fetchAllTeamLabels(team)
} catch (error) {
if (isAuthError(error)) {
clearToken(entry.workspace.id)
throw error
}
console.warn('[linear] getTeamLabels failed:', error)
return []
} finally {
release()
}
return readTeamResource(teamId, workspaceId, fetchAllTeamLabels, 'getTeamLabels')
}
export async function getTeamLabelsOrThrow(
teamId: string,
workspaceId?: string | null
): Promise<LinearLabel[]> {
const entry = getClients(workspaceId)[0]
if (!entry) {
return []
}
await acquire()
try {
const team = await entry.client.team(teamId)
return await fetchAllTeamLabels(team)
} catch (error) {
if (isAuthError(error)) {
clearToken(entry.workspace.id)
}
throw error
} finally {
release()
}
return readTeamResource(teamId, workspaceId, fetchAllTeamLabels)
}
export async function getTeamMembers(
teamId: string,
workspaceId?: string | null
): Promise<LinearMember[]> {
const entry = getClients(workspaceId)[0]
if (!entry) {
return []
}
await acquire()
try {
const team = await entry.client.team(teamId)
return await fetchAllTeamMembers(team)
} catch (error) {
if (isAuthError(error)) {
clearToken(entry.workspace.id)
throw error
}
console.warn('[linear] getTeamMembers failed:', error)
return []
} finally {
release()
}
return readTeamResource(teamId, workspaceId, fetchAllTeamMembers, 'getTeamMembers')
}
export async function getTeamMembersOrThrow(
teamId: string,
workspaceId?: string | null
): Promise<LinearMember[]> {
const entry = getClients(workspaceId)[0]
if (!entry) {
return []
}
await acquire()
try {
const team = await entry.client.team(teamId)
return await fetchAllTeamMembers(team)
} catch (error) {
if (isAuthError(error)) {
clearToken(entry.workspace.id)
}
throw error
} finally {
release()
}
return readTeamResource(teamId, workspaceId, fetchAllTeamMembers)
}
export async function getViewerForWorkspaceOrThrow(
+159 -3
View File
@@ -12142,21 +12142,177 @@ describe('Store host-partitioned workspace sessions', () => {
expect(session.terminalTopologyRevisionByRepoId?.['repo-gone']).toBeUndefined()
})
it('drops a corrupt host partition to defaults without failing the others', async () => {
it('resets only the corrupt required field of a host partition, not the partition', async () => {
const worktreeId = 'repo-1::/worktree'
writeDataFile({
schemaVersion: 1,
workspaceSessionsByHostId: {
'runtime:good': makeHostSession('good-repo'),
// activeRepoId must be string|null; a number fails the zod parse.
'runtime:bad': { ...makeHostSession('x'), activeRepoId: 123 }
'runtime:bad': {
...makeHostSession('x'),
activeRepoId: 123,
tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'bad-host-tab', worktreeId })] }
}
}
})
const store = await createStore()
expect(store.getWorkspaceSession('runtime:good').activeRepoId).toBe('good-repo')
// Bad partition collapses to defaults rather than poisoning the map.
// The unsalvageable field falls back to its default; the partition's tabs survive.
expect(store.getWorkspaceSession('runtime:bad').activeRepoId).toBeNull()
expect(
store.getWorkspaceSession('runtime:bad').tabsByWorktree[worktreeId]?.map((tab) => tab.id)
).toEqual(['bad-host-tab'])
})
it('keeps every other worktree when the local session has a corrupt required field', async () => {
const worktreeId = 'repo-1::/worktree'
writeDataFile({
schemaVersion: 1,
workspaceSession: {
...makeHostSession('local-repo'),
// A projected/truncated write can leave a top-level field the wrong type;
// that must not cost every worktree's tabs the way a full reset did.
activeTabId: 42,
tabsByWorktree: {
[worktreeId]: [makeTerminalTab({ id: 'local-keep', worktreeId })]
}
}
})
const store = await createStore()
const session = store.getWorkspaceSession('local')
expect(session.activeTabId).toBeNull()
expect(session.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual(['local-keep'])
})
type PersistedSessionsFile = {
workspaceSession?: {
tabsByWorktree?: Record<string, { id: string }[]>
sleepingAgentSessionsByPaneKey?: Record<string, unknown>
}
workspaceSessionsByHostId?: Record<
string,
{ tabsByWorktree?: Record<string, { id: string }[]> }
>
}
// Why: flush() writes whatever the state hash says is dirty, so it passes even
// when nothing scheduled a save — it cannot see the repair write at all. Loading
// once first canonicalizes the profile (a second load of a canonical file
// schedules nothing), so a later rewrite proves the salvage scheduled it.
async function loadAndAwaitScheduledSave(): Promise<void> {
vi.useFakeTimers()
try {
const store = await createStore()
vi.advanceTimersByTime(10_000)
await store.waitForPendingWrite()
} finally {
vi.useRealTimers()
}
}
async function canonicalize(fixture: Record<string, unknown>): Promise<PersistedSessionsFile> {
writeDataFile(fixture)
await loadAndAwaitScheduledSave()
const canonical = readFileSync(dataFile(), 'utf-8')
// Why: the save assertions below are only meaningful if a clean load schedules
// nothing. Prove that here rather than assume it — a future migration that
// dirtied every load would otherwise leave those tests silently vacuous.
await loadAndAwaitScheduledSave()
expect(readFileSync(dataFile(), 'utf-8')).toBe(canonical)
return JSON.parse(canonical) as PersistedSessionsFile
}
it('schedules a save for a salvaged local session instead of re-salvaging every launch', async () => {
const worktreeId = 'repo-1::/worktree'
const profile = await canonicalize({
schemaVersion: 1,
workspaceSession: {
...makeHostSession('local-repo'),
tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'tab-keep', worktreeId })] }
}
})
const tabs = profile.workspaceSession?.tabsByWorktree?.[worktreeId]
expect(tabs).toBeDefined()
tabs!.push({ id: 'tab-corrupt' })
writeDataFile(profile)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
await loadAndAwaitScheduledSave()
expect(warn).toHaveBeenCalledWith(
'[persistence] Salvaged workspace session; dropped corrupt entries:',
{ count: 1, fields: ['tabsByWorktree'], detailsTruncated: false }
)
expect(JSON.stringify(warn.mock.calls)).not.toContain(worktreeId)
} finally {
warn.mockRestore()
}
const persisted = readDataFile() as PersistedSessionsFile
expect(persisted.workspaceSession?.tabsByWorktree?.[worktreeId]?.map((tab) => tab.id)).toEqual([
'tab-keep'
])
})
it('schedules a save for salvaged host partitions', async () => {
const worktreeId = 'repo-1::/worktree'
const profile = await canonicalize({
schemaVersion: 1,
workspaceSessionsByHostId: {
'runtime:env-a': {
...makeHostSession('runtime-repo'),
tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'runtime-keep', worktreeId })] }
},
'ssh:target-b': {
...makeHostSession('ssh-repo'),
tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'ssh-keep', worktreeId })] }
}
}
})
const partitions = profile.workspaceSessionsByHostId
const runtimeTabs = partitions?.['runtime:env-a']?.tabsByWorktree?.[worktreeId]
const sshTabs = partitions?.['ssh:target-b']?.tabsByWorktree?.[worktreeId]
expect(runtimeTabs).toBeDefined()
expect(sshTabs).toBeDefined()
runtimeTabs!.push({ id: 'runtime-corrupt' })
sshTabs!.push({ id: 'ssh-corrupt' })
const mutablePartitions = partitions as Record<string, unknown>
mutablePartitions['runtime:broken'] = 'not a session'
writeDataFile(profile)
await loadAndAwaitScheduledSave()
const persisted = (readDataFile() as PersistedSessionsFile).workspaceSessionsByHostId
expect(
persisted?.['runtime:env-a']?.tabsByWorktree?.[worktreeId]?.map((tab) => tab.id)
).toEqual(['runtime-keep'])
expect(persisted?.['ssh:target-b']?.tabsByWorktree?.[worktreeId]?.map((tab) => tab.id)).toEqual(
['ssh-keep']
)
expect(persisted).not.toHaveProperty('runtime:broken')
})
it('writes back sleeping-agent records dropped during salvage', async () => {
const profile = await canonicalize({
schemaVersion: 1,
workspaceSession: {
...makeHostSession('local-repo'),
sleepingAgentSessionsByPaneKey: {}
}
})
profile.workspaceSession!.sleepingAgentSessionsByPaneKey = {
'tab-bad:leaf': { paneKey: 'different:leaf' }
}
writeDataFile(profile)
await loadAndAwaitScheduledSave()
const persisted = readDataFile() as PersistedSessionsFile
expect(persisted.workspaceSession?.sleepingAgentSessionsByPaneKey).toBeUndefined()
})
})
+45 -10
View File
@@ -134,7 +134,7 @@ import {
ONBOARDING_FLOW_VERSION,
ONBOARDING_FINAL_STEP
} from '../shared/constants'
import { parseWorkspaceSession } from '../shared/workspace-session-schema'
import { parseWorkspaceSessionSalvaging } from '../shared/workspace-session-salvage'
import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display'
import { normalizeStatusBarUsageMode } from '../shared/status-bar-usage-mode'
import { isExistingPersistedProfile } from '../shared/project-order-manual-default-notice'
@@ -555,15 +555,27 @@ function workspaceSessionPatchNeedsFullNormalization(patch: WorkspaceSessionPatc
)
}
function workspaceSessionSalvageLogDetails(result: {
droppedCount: number
droppedPaths: string[]
}): { count: number; fields: string[]; detailsTruncated: boolean } {
return {
count: result.droppedCount,
fields: [...new Set(result.droppedPaths.map((path) => path.split('.', 1)[0]))],
detailsTruncated: result.droppedCount > result.droppedPaths.length
}
}
/** Normalize non-'local' host partitions; 'local' (the legacy workspaceSession blob) is dropped so the two surfaces never diverge.
* Each partition is zod-validated independently, so one corrupt host drops to defaults without taking out the others. Idempotent. */
function parseWorkspaceSessionsByHostId(
raw: unknown,
defaults: WorkspaceSessionState
): Partial<Record<ExecutionHostId, WorkspaceSessionState>> {
): { partitions: Partial<Record<ExecutionHostId, WorkspaceSessionState>>; repaired: boolean } {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return {}
return { partitions: {}, repaired: raw !== undefined }
}
let repaired = false
const partitions: Partial<Record<ExecutionHostId, WorkspaceSessionState>> = {}
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
const hostId = normalizeExecutionHostId(key)
@@ -571,17 +583,25 @@ function parseWorkspaceSessionsByHostId(
if (!hostId || hostId === LOCAL_EXECUTION_HOST_ID) {
continue
}
const result = parseWorkspaceSession(value)
const result = parseWorkspaceSessionSalvaging(value)
if (!result.ok) {
repaired = true
console.error(
`[persistence] Corrupt workspace session for host ${hostId}, using defaults:`,
result.error
)
continue
}
if (result.droppedCount > 0) {
console.warn(
`[persistence] Salvaged workspace session for host ${hostId}; dropped corrupt entries:`,
workspaceSessionSalvageLogDetails(result)
)
repaired = true
}
partitions[hostId] = { ...defaults, ...result.value }
}
return partitions
return { partitions, repaired }
}
function backupPath(dataFile: string, index: number): string {
@@ -3651,7 +3671,7 @@ export class Store {
if (parsed.workspaceSession === undefined) {
return defaults.workspaceSession
}
const result = parseWorkspaceSession(parsed.workspaceSession)
const result = parseWorkspaceSessionSalvaging(parsed.workspaceSession)
if (!result.ok) {
console.error(
'[persistence] Corrupt workspace session, using defaults:',
@@ -3659,13 +3679,28 @@ export class Store {
)
return defaults.workspaceSession
}
if (result.droppedCount > 0) {
console.warn(
'[persistence] Salvaged workspace session; dropped corrupt entries:',
workspaceSessionSalvageLogDetails(result)
)
// Why: salvage repairs only the in-memory session; without a save the corrupt entries stay on disk and get re-dropped every launch.
this.loadNeedsSave = true
}
return { ...defaults.workspaceSession, ...result.value }
})(),
// Why: per-host session partitions, validated independently; 'local' stays in workspaceSession for downgrade compat.
workspaceSessionsByHostId: parseWorkspaceSessionsByHostId(
parsed.workspaceSessionsByHostId,
defaults.workspaceSession
),
workspaceSessionsByHostId: (() => {
const { partitions, repaired } = parseWorkspaceSessionsByHostId(
parsed.workspaceSessionsByHostId,
defaults.workspaceSession
)
if (repaired) {
// Why: salvage repairs only the in-memory partitions; without a save the corrupt entries stay on disk and get re-dropped every launch.
this.loadNeedsSave = true
}
return partitions
})(),
sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget),
deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases)
? parsed.deletedSshConfigAliases.filter(
@@ -30,6 +30,7 @@ type Harness = {
fsMock: {
existsSync: ReturnType<typeof vi.fn>
readFileSync: ReturnType<typeof vi.fn>
statSync: ReturnType<typeof vi.fn>
}
handlers: Record<string, HookHandler>
processEnv: Record<string, string | undefined>
@@ -62,6 +63,7 @@ function createHarness(args: {
argv?: string[]
existsSync?: (path: string) => boolean
readFileSync?: (path: string, encoding: string) => string
statSync?: (path: string) => { mtimeMs: number; size: number; ino: number }
fetchImpl?: (...params: Parameters<typeof fetch>) => Promise<unknown>
}): Harness {
const fetchMock = vi.fn(
@@ -86,6 +88,12 @@ function createHarness(args: {
const fsMock = {
existsSync: vi.fn(args.existsSync ?? (() => false)),
statSync: vi.fn(
args.statSync ??
((path: string) => {
throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' })
})
),
readFileSync: vi.fn(
args.readFileSync ??
((path: string) => {
@@ -562,6 +570,31 @@ describe('getPiAgentStatusExtensionSource', () => {
)
})
it('uses current Windows coordinates when a same-token guest endpoint is stale', async () => {
const endpointPath = '/home/u/.orca-wsl/agent-hooks/instance-test/endpoint.env'
const harness = createHarness({
kind: 'prime-agent',
env: { WSL_DISTRO_NAME: 'Ubuntu', ORCA_AGENT_HOOK_ENDPOINT: endpointPath },
existsSync: (path) => path === '/mnt/c/Windows/System32/curl.exe',
statSync: () => ({ mtimeMs: 1, size: 80, ino: 1 }),
readFileSync: (path) => {
if (path === endpointPath) {
return 'ORCA_AGENT_HOOK_PORT=9999\nORCA_AGENT_HOOK_TOKEN=token-1\n'
}
throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' })
},
fetchImpl: vi.fn(async () => {
throw new Error('stale guest relay')
})
})
await harness.callHook('agent_start')
expect(harness.fetchMock.mock.calls[0]?.[0]).toBe('http://127.0.0.1:9999/hook/prime-agent')
await vi.waitFor(() => expect(harness.spawnMock).toHaveBeenCalledTimes(1))
expect(harness.spawnMock.mock.calls[0]?.[1]).toContain('http://127.0.0.1:4321/hook/prime-agent')
})
it('probes WSL evidence and the curl path once per process', async () => {
const harness = createHarness({
kind: 'omp',
+3 -81
View File
@@ -13,6 +13,7 @@
import type { PiAgentKind } from '../../shared/pi-agent-kind'
import { getPiAgentStatusHandlerSourceLines } from './agent-status-handler-source'
import { getPiAgentStatusRuntimeDetectionSourceLines } from './agent-status-runtime-detection-source'
import { getPiAgentStatusWslCurlSourceLines } from './agent-status-wsl-curl-source'
export const ORCA_PI_AGENT_STATUS_EXTENSION_FILE = 'orca-agent-status.ts'
@@ -228,92 +229,13 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin
' // Why: status reporting must never fail the pi run just because Orca',
' // is unavailable or the loopback request failed (e.g. Orca restart).',
' if (!isWslRuntime()) return',
' postViaWindowsCurl(url, coords, body)',
' postViaWindowsCurl(body, ompRuntime)',
' } finally {',
' if (timeout) clearTimeout(timeout)',
' }',
'}',
'',
'// Why: WSL-ness and curl.exe presence cannot change within a process',
'// lifetime; re-probing /proc and /mnt/c on every failed event would add',
'// filesystem work to the per-event path.',
'let cachedIsWslRuntime: boolean | null = null',
'let cachedWindowsCurlPath: string | null | undefined',
'',
'function isWslRuntime(): boolean {',
' if (cachedIsWslRuntime !== null) return cachedIsWslRuntime',
' cachedIsWslRuntime = detectWslRuntime()',
' return cachedIsWslRuntime',
'}',
'',
'function detectWslRuntime(): boolean {',
' if (process.env.WSL_DISTRO_NAME) return true',
' try {',
" const fs = require('fs')",
" for (const path of ['/proc/sys/kernel/osrelease', '/proc/version']) {",
' try {',
" const contents = String(fs.readFileSync(path, 'utf8'))",
' if (/microsoft|wsl/i.test(contents)) return true',
' } catch {',
' // Why: probe the next runtime hint when a proc file is absent or unreadable.',
' }',
' }',
' } catch {',
' return false',
' }',
' return false',
'}',
'',
'function resolveWindowsCurlPath(): string | null {',
' if (cachedWindowsCurlPath !== undefined) return cachedWindowsCurlPath',
' try {',
" const fs = require('fs')",
" const curlPath = '/mnt/c/Windows/System32/curl.exe'",
' cachedWindowsCurlPath = fs.existsSync(curlPath) ? curlPath : null',
' } catch {',
' cachedWindowsCurlPath = null',
' }',
' return cachedWindowsCurlPath',
'}',
'',
'// Why: WSL loopback is not the Windows loopback, so a WSL-side POST cannot',
'// reach Orca. curl.exe runs on the Windows side, where 127.0.0.1 IS the',
'// listener Orca binds. Fire-and-forget: blocking on the spawn would stall',
'// the pi event loop (and the TUI) on every hook event.',
'function postViaWindowsCurl(url: string, coords: { token: string }, body: string): void {',
' const curlPath = resolveWindowsCurlPath()',
' if (!curlPath) return',
' try {',
" const { spawn } = require('child_process')",
' const child = spawn(',
' curlPath,',
' [',
" '-sS',",
' // Why: the spawn is detached from the event loop, so these bounds',
' // size a background process, not TUI latency. WSL->Win32 interop',
' // connects can exceed 0.5s on loaded machines (observed 3/3 drops',
' // to a healthy listener); size for delivery, not snappiness.',
" '--connect-timeout', '3',",
" '--max-time', '10',",
" '--noproxy', '127.0.0.1',",
" '-o', 'NUL',",
" '-X', 'POST',",
" '-H', 'Content-Type: application/json',",
" '-H', `X-Orca-Agent-Hook-Token: ${coords.token}`,",
" '--data-binary', '@-',",
' url',
' ],',
" { stdio: ['pipe', 'ignore', 'ignore'] }",
' )',
" child.on('error', () => {})",
" child.stdin.on('error', () => {})",
' child.stdin.end(body)',
' } catch {',
' // Why: the bridge is best-effort; a failed spawn must not surface',
' // inside the pi TUI.',
' }',
'}',
'',
...getPiAgentStatusWslCurlSourceLines(),
...getPiAgentStatusHandlerSourceLines(kind)
].join('\n')
}
@@ -0,0 +1,81 @@
export function getPiAgentStatusWslCurlSourceLines(): string[] {
return [
'// Why: WSL-ness and curl.exe presence cannot change within a process',
'// lifetime; re-probing /proc and /mnt/c on every failed event would add',
'// filesystem work to the per-event path.',
'let cachedIsWslRuntime: boolean | null = null',
'let cachedWindowsCurlPath: string | null | undefined',
'',
'function isWslRuntime(): boolean {',
' if (cachedIsWslRuntime !== null) return cachedIsWslRuntime',
' cachedIsWslRuntime = detectWslRuntime()',
' return cachedIsWslRuntime',
'}',
'',
'function detectWslRuntime(): boolean {',
' if (process.env.WSL_DISTRO_NAME) return true',
' try {',
" const fs = require('fs')",
" for (const path of ['/proc/sys/kernel/osrelease', '/proc/version']) {",
' try {',
" const contents = String(fs.readFileSync(path, 'utf8'))",
' if (/microsoft|wsl/i.test(contents)) return true',
' } catch {',
' // Why: probe the next runtime hint when a proc file is absent or unreadable.',
' }',
' }',
' } catch {',
' return false',
' }',
' return false',
'}',
'',
'function resolveWindowsCurlPath(): string | null {',
' if (cachedWindowsCurlPath !== undefined) return cachedWindowsCurlPath',
' try {',
" const fs = require('fs')",
" const curlPath = '/mnt/c/Windows/System32/curl.exe'",
' cachedWindowsCurlPath = fs.existsSync(curlPath) ? curlPath : null',
' } catch {',
' cachedWindowsCurlPath = null',
' }',
' return cachedWindowsCurlPath',
'}',
'',
'// Why: WSL loopback is not the Windows loopback, so use curl.exe on the host.',
'function postViaWindowsCurl(body: string, ompRuntime: boolean): void {',
' const curlPath = resolveWindowsCurlPath()',
' const windowsPort = process.env.ORCA_AGENT_HOOK_PORT',
' const windowsToken = process.env.ORCA_AGENT_HOOK_TOKEN',
' if (!curlPath || !windowsPort || !windowsToken) return',
' // Why: a stale guest endpoint must fall back to current host coordinates.',
' const windowsUrl = `http://127.0.0.1:${windowsPort}${resolveHookPath(ompRuntime)}`',
' try {',
" const { spawn } = require('child_process')",
' const child = spawn(',
' curlPath,',
' [',
" '-sS',",
' // Why: detached delivery may take seconds under loaded WSL interop.',
" '--connect-timeout', '3',",
" '--max-time', '10',",
" '--noproxy', '127.0.0.1',",
" '-o', 'NUL',",
" '-X', 'POST',",
" '-H', 'Content-Type: application/json',",
" '-H', `X-Orca-Agent-Hook-Token: ${windowsToken}`,",
" '--data-binary', '@-',",
' windowsUrl',
' ],',
" { stdio: ['pipe', 'ignore', 'ignore'] }",
' )',
" child.on('error', () => {})",
" child.stdin.on('error', () => {})",
' child.stdin.end(body)',
' } catch {',
' // Why: status delivery must not surface inside the agent TUI.',
' }',
'}',
''
]
}
@@ -94,6 +94,10 @@ describe('PiTitlebarExtensionService', () => {
rmSync(join(userDataDir, 'pi-agent-overlays'), { recursive: true, force: true })
rmSync(join(userDataDir, 'omp-agent-overlays'), { recursive: true, force: true })
rmSync(join(userDataDir, 'omp-managed-status-extension'), { recursive: true, force: true })
rmSync(join(userDataDir, 'prime-agent-managed-status-extension'), {
recursive: true,
force: true
})
})
function expectPiHomeIntact(): void {
@@ -181,6 +185,21 @@ describe('PiTitlebarExtensionService', () => {
expectPiHomeIntact()
})
it('writes a Prime status-only extension outside the guest-owned config dir', () => {
const svc = new PiTitlebarExtensionService()
const env = svc.buildStatusOnlyPtyEnv('prime-agent')
const statusPath = join(
userDataDir,
'prime-agent-managed-status-extension',
'orca-agent-status.ts'
)
expect(env).toEqual({ ORCA_PRIME_AGENT_STATUS_EXTENSION: statusPath })
expect(readFileSync(statusPath, 'utf8')).toContain('/hook/prime-agent')
expect(readFileSync(statusPath, 'utf8')).not.toContain("return '/hook/omp'")
expectPiHomeIntact()
})
it('uses the same source dir for multiple PTYs with the same Pi dir', () => {
const svc = new PiTitlebarExtensionService()
const firstEnv = svc.buildPtyEnv('pty-shared-1', piHome, 'pi')
+17 -5
View File
@@ -27,7 +27,10 @@ export const isSafeDescendCandidate = sharedIsSafeDescendCandidate
const PI_AGENT_SUBDIR = 'agent'
const ORCA_MANAGED_EXTENSION_MARKER = '@orca-managed-pi-extension'
const OMP_MANAGED_STATUS_EXTENSION_DIR = 'omp-managed-status-extension'
const MANAGED_STATUS_EXTENSION_DIR: Record<'omp' | 'prime-agent', string> = {
omp: 'omp-managed-status-extension',
'prime-agent': 'prime-agent-managed-status-extension'
}
type ManagedExtensionWriteResult = 'written' | 'skipped-user-owned' | 'failed'
@@ -121,8 +124,11 @@ export class PiTitlebarExtensionService {
}
}
private writeOmpFallbackStatusExtension(source: string): string | undefined {
const fallbackDir = join(app.getPath('userData'), OMP_MANAGED_STATUS_EXTENSION_DIR)
private writeFallbackStatusExtension(
source: string,
kind: keyof typeof MANAGED_STATUS_EXTENSION_DIR
): string | undefined {
const fallbackDir = join(app.getPath('userData'), MANAGED_STATUS_EXTENSION_DIR[kind])
try {
mkdirSync(fallbackDir, { recursive: true })
} catch {
@@ -165,7 +171,7 @@ export class PiTitlebarExtensionService {
statusResult === 'written'
? statusExtensionPath
: kind === 'omp'
? this.writeOmpFallbackStatusExtension(statusSource)
? this.writeFallbackStatusExtension(statusSource, 'omp')
: undefined
}
}
@@ -195,7 +201,7 @@ export class PiTitlebarExtensionService {
if (!existsSync(sourceAgentDir) && !materializeDefaultHome) {
if (kind === 'omp') {
const statusSource = withOrcaManagedExtensionMarker(getPiAgentStatusExtensionSource(kind))
const statusExtensionPath = this.writeOmpFallbackStatusExtension(statusSource)
const statusExtensionPath = this.writeFallbackStatusExtension(statusSource, 'omp')
return statusExtensionPath ? { ORCA_OMP_STATUS_EXTENSION: statusExtensionPath } : {}
}
return {}
@@ -220,6 +226,12 @@ export class PiTitlebarExtensionService {
return env
}
buildStatusOnlyPtyEnv(kind: 'prime-agent'): Record<string, string> {
const source = withOrcaManagedExtensionMarker(getPiAgentStatusExtensionSource(kind))
const statusExtensionPath = this.writeFallbackStatusExtension(source, kind)
return statusExtensionPath ? { ORCA_PRIME_AGENT_STATUS_EXTENSION: statusExtensionPath } : {}
}
clearPty(ptyId: string): void {
// Why: PTY teardown doesn't know which kind was launched (the daemon
// exit path discards the launch command). Sweep both old PTY-scoped
@@ -978,6 +978,31 @@ describe('LocalPtyProvider', () => {
expect(spawnCall[2].env.HISTFILE).toContain('terminal-history-wsl/Debian')
})
it('translates a POSIX cwd through the preferred WSL distro', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
await provider.spawn({
cols: 80,
rows: 24,
worktreeId: 'repo-1::C:\\Users\\jin\\repo',
cwd: '/home/jin/repo',
shellOverride: 'wsl.exe',
terminalWindowsWslDistro: 'Debian'
})
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[0]).toBe('wsl.exe')
expect(spawnCall[1]).toEqual([
'-d',
'Debian',
'--',
'sh',
'-c',
expect.stringContaining("cd '/home/jin/repo'")
])
expect(spawnCall[2].cwd).not.toBe('/home/jin/repo')
})
it('resolves and persists the default distro for Windows cwd WSL terminals', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const buildSpawnEnv = vi.fn(
+2 -2
View File
@@ -239,9 +239,9 @@ function getWslContextFromWorktreeId(
*/
function getWslContextFromPreferredDistro(
distro: string | null | undefined
): { distro: string } | undefined {
): { distro: string; treatPosixCwdAsWsl: true } | undefined {
const trimmed = distro?.trim()
return trimmed ? { distro: trimmed } : undefined
return trimmed ? { distro: trimmed, treatPosixCwdAsWsl: true } : undefined
}
/**
@@ -526,6 +526,8 @@ describePosix('local PTY shell-ready launch config', () => {
'[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"'
const agentTeamsPathRestoreLine = '[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0'
const ompWrapperLine = 'command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"'
const primeWrapperLine =
'command prime-agent --extension "${ORCA_PRIME_AGENT_STATUS_EXTENSION}" "$@"'
expect(zshrc).toContain(restoreLine)
expect(zlogin).toContain(restoreLine)
expect(bashRc).toContain(restoreLine)
@@ -547,6 +549,9 @@ describePosix('local PTY shell-ready launch config', () => {
expect(zshrc).toContain(ompWrapperLine)
expect(zlogin).toContain(ompWrapperLine)
expect(bashRc).toContain(ompWrapperLine)
expect(zshrc).toContain(primeWrapperLine)
expect(zlogin).toContain(primeWrapperLine)
expect(bashRc).toContain(primeWrapperLine)
})
// Why: issue #2422 — without OSC 133 C/D markers, bash sessions kept the worktree spinner "working" ~30min after the agent exited.
@@ -15,6 +15,7 @@ import {
isPowerShellExecutableName
} from '../powershell-osc133-bootstrap'
import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
import { getPosixPrimeAgentShellWrapper } from '../pty/prime-agent-shell-wrapper'
import { buildStartupCommandSubmission } from '../../shared/startup-command-submission'
import {
getFishShellReadyInitCommand,
@@ -129,6 +130,7 @@ __orca_restore_agent_teams_path
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
${getPosixOmpShellWrapper()}
${getPosixPrimeAgentShellWrapper()}
# Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts.
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
# Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent
@@ -245,6 +247,7 @@ if [[ ! -o login ]]; then
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
${getPosixOmpShellWrapper()}
${getPosixPrimeAgentShellWrapper()}
# Why: Codex must keep using Orca's runtime CODEX_HOME after rc files.
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
fi
@@ -305,6 +308,7 @@ __orca_restore_agent_teams_path
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
${getPosixOmpShellWrapper()}
${getPosixPrimeAgentShellWrapper()}
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER_ESCAPED)}
${getZshFinalZdotdirRestoreBlock()}
@@ -280,13 +280,16 @@ describe('resolveWindowsShellLaunchArgs', () => {
expect(existsSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'))).toBe(true)
expect(existsSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'))).toBe(true)
// Why: the point of materializing wrappers for WSL is that a typed `omp`
// picks up Orca's status extension; pin that shim end to end.
// Why: typed agents need the host-managed status extension in WSL.
const bashRcfile = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8')
const zshLogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8')
for (const wrapperFile of [bashRcfile, zshLogin]) {
expect(wrapperFile).toContain('command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"')
expect(wrapperFile).toContain('omp() { __orca_omp "$@"; }')
expect(wrapperFile).toContain(
'command prime-agent --extension "${ORCA_PRIME_AGENT_STATUS_EXTENSION}" "$@"'
)
expect(wrapperFile).toContain('prime-agent() { __orca_prime_agent "$@"; }')
}
})
@@ -0,0 +1,244 @@
import { spawnSync } from 'node:child_process'
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { getPosixPrimeAgentShellWrapper } from './prime-agent-shell-wrapper'
const describePosix = process.platform === 'win32' ? describe.skip : describe
const hasBash = process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0
const itWithBash = hasBash ? it : it.skip
const tempDirs: string[] = []
function makeFixture(): {
root: string
extensionPath: string
capturePath: string
endpointPath: string
primeHome: string
rcfile: string
} {
const root = mkdtempSync(join(tmpdir(), 'orca-prime-wrapper-'))
tempDirs.push(root)
const binDir = join(root, 'bin')
const primeHome = join(root, 'guest-prime-home')
const extensionPath = join(root, 'orca-agent-status.ts')
const capturePath = join(root, 'capture')
const endpointPath = join(
root,
'.orca-wsl',
'agent-hooks',
'instance-testinstance',
'endpoint.env'
)
const rcfile = join(root, 'rcfile')
mkdirSync(binDir)
mkdirSync(primeHome)
mkdirSync(dirname(endpointPath), { recursive: true })
writeFileSync(extensionPath, 'export default {}')
writeFileSync(endpointPath, 'ORCA_AGENT_HOOK_PORT=4567\nORCA_AGENT_HOOK_TOKEN=current-token\n')
writeFileSync(rcfile, getPosixPrimeAgentShellWrapper())
writeFileSync(
join(binDir, 'prime-agent'),
`#!/bin/sh
{
printf 'HOME=%s\n' "$PRIME_AGENT_CODING_AGENT_DIR"
printf 'ENDPOINT=%s\n' "$ORCA_AGENT_HOOK_ENDPOINT"
i=0
for arg in "$@"; do
i=$((i + 1))
printf 'ARG%s=%s\n' "$i" "$arg"
done
} > "$ORCA_CAPTURE_FILE"
if [ "\${1:-}" = "config" ]; then
printf 'preserved\n' > "$PRIME_AGENT_CODING_AGENT_DIR/config.yml"
fi
`,
{ mode: 0o755 }
)
chmodSync(join(binDir, 'prime-agent'), 0o755)
return { root, extensionPath, capturePath, endpointPath, primeHome, rcfile }
}
function runPrime(
fixture: ReturnType<typeof makeFixture>,
command: string,
extensionPath = fixture.extensionPath
): string {
const result = spawnSync('bash', ['--noprofile', '--rcfile', fixture.rcfile, '-ic', command], {
cwd: fixture.root,
env: {
...process.env,
HOME: fixture.root,
PATH: `${join(fixture.root, 'bin')}:${process.env.PATH ?? ''}`,
PRIME_AGENT_CODING_AGENT_DIR: fixture.primeHome,
ORCA_PRIME_AGENT_STATUS_EXTENSION: extensionPath,
ORCA_WSL_HOOK_INSTANCE: 'testinstance',
ORCA_AGENT_HOOK_ENDPOINT: '/mnt/c/current/endpoint.cmd',
ORCA_AGENT_HOOK_PORT: '4567',
ORCA_AGENT_HOOK_TOKEN: 'current-token',
ORCA_CAPTURE_FILE: fixture.capturePath
},
encoding: 'utf8'
})
expect(result.status, result.stderr).toBe(0)
return readFileSync(fixture.capturePath, 'utf8')
}
describePosix('Prime Agent shell wrapper', () => {
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
itWithBash('injects the managed extension into an interactive launch', () => {
const fixture = makeFixture()
const capture = runPrime(fixture, 'prime-agent ask')
expect(capture).toContain(`HOME=${fixture.primeHome}`)
expect(capture).toContain(`ENDPOINT=${fixture.endpointPath}`)
expect(capture).toContain('ARG1=--extension')
expect(capture).toContain(`ARG2=${fixture.extensionPath}`)
expect(capture).toContain('ARG3=ask')
})
itWithBash('preserves config subcommands and the guest Prime home', () => {
const fixture = makeFixture()
const capture = runPrime(fixture, 'prime-agent config')
expect(capture).toContain(`HOME=${fixture.primeHome}`)
expect(capture).toContain('ARG1=config')
expect(capture).not.toContain('ARG1=--extension')
expect(readFileSync(join(fixture.primeHome, 'config.yml'), 'utf8')).toBe('preserved\n')
})
itWithBash.each([
'agents',
'doctor',
'help',
'list',
'model',
'package',
'rename',
'schedule',
'send',
'session',
'shutdown',
'status',
'stop',
'update'
])('preserves the %s subcommand argv', (subcommand) => {
const fixture = makeFixture()
const capture = runPrime(fixture, `prime-agent ${subcommand}`)
expect(capture).toContain(`ARG1=${subcommand}`)
expect(capture).not.toContain('ARG1=--extension')
})
itWithBash.each(['stop', 'rename'])(
'preserves --daemon-socket before the %s management command',
(subcommand) => {
const fixture = makeFixture()
const capture = runPrime(
fixture,
`prime-agent --daemon-socket /tmp/prime.sock ${subcommand} agent-123`
)
expect(capture).toContain('ARG1=--daemon-socket')
expect(capture).toContain('ARG2=/tmp/prime.sock')
expect(capture).toContain(`ARG3=${subcommand}`)
expect(capture).not.toContain(fixture.extensionPath)
}
)
itWithBash('injects the extension after the attach agent operand', () => {
const fixture = makeFixture()
const capture = runPrime(fixture, 'prime-agent attach agent-123 --follow')
expect(capture).toContain('ARG1=attach')
expect(capture).toContain('ARG2=agent-123')
expect(capture).toContain('ARG3=--extension')
expect(capture).toContain(`ARG4=${fixture.extensionPath}`)
expect(capture).toContain('ARG5=--follow')
})
itWithBash('preserves attach help without inventing an agent operand', () => {
const fixture = makeFixture()
const capture = runPrime(fixture, 'prime-agent attach --help')
expect(capture).toContain('ARG1=attach')
expect(capture).toContain('ARG2=--help')
expect(capture).not.toContain(fixture.extensionPath)
})
itWithBash('does not override an explicit user extension', () => {
const fixture = makeFixture()
const capture = runPrime(fixture, 'prime-agent --extension /tmp/user-extension.ts ask')
expect(capture).toContain('ARG1=--extension')
expect(capture).toContain('ARG2=/tmp/user-extension.ts')
expect(capture).not.toContain(fixture.extensionPath)
})
itWithBash('does not add a second extension to attach', () => {
const fixture = makeFixture()
const capture = runPrime(
fixture,
'prime-agent attach agent-123 --extension /tmp/user-extension.ts'
)
expect(capture).toContain('ARG1=attach')
expect(capture).toContain('ARG2=agent-123')
expect(capture).toContain('ARG3=--extension')
expect(capture).toContain('ARG4=/tmp/user-extension.ts')
expect(capture).not.toContain(fixture.extensionPath)
})
itWithBash('degrades to the original argv when the managed extension is unavailable', () => {
const fixture = makeFixture()
const capture = runPrime(fixture, 'prime-agent ask', join(fixture.root, 'missing.ts'))
expect(capture).toContain('ARG1=ask')
expect(capture).not.toContain('ARG1=--extension')
})
itWithBash('keeps the stable guest endpoint path while its relay refreshes stale data', () => {
const fixture = makeFixture()
writeFileSync(fixture.endpointPath, 'ORCA_AGENT_HOOK_PORT=4567\nORCA_AGENT_HOOK_TOKEN=stale\n')
const capture = runPrime(fixture, 'prime-agent ask')
expect(capture).toContain(`ENDPOINT=${fixture.endpointPath}`)
expect(capture).toContain('ARG1=--extension')
})
itWithBash('uses the guest endpoint path when the relay binds a fallback port', () => {
const fixture = makeFixture()
writeFileSync(
fixture.endpointPath,
'ORCA_AGENT_HOOK_PORT=9876\nORCA_AGENT_HOOK_TOKEN=current-token\n'
)
const capture = runPrime(fixture, 'prime-agent ask')
expect(capture).toContain(`ENDPOINT=${fixture.endpointPath}`)
expect(capture).toContain('ARG1=--extension')
})
itWithBash('does not define wrapper helpers when the bridge env is absent', () => {
const fixture = makeFixture()
const result = spawnSync(
'bash',
['--noprofile', '--rcfile', fixture.rcfile, '-ic', 'type -t __orca_prime_agent'],
{
cwd: fixture.root,
env: { ...process.env, ORCA_PRIME_AGENT_STATUS_EXTENSION: '' },
encoding: 'utf8'
}
)
expect(result.status).not.toBe(0)
expect(result.stdout.trim()).toBe('')
})
})
+74
View File
@@ -0,0 +1,74 @@
import { wslHookRelayEndpointFilePath } from '../../shared/wsl-hook-relay-contract'
const PRIME_AGENT_SUBCOMMANDS = [
'agents',
'config',
'doctor',
'help',
'list',
'model',
'package',
'rename',
'schedule',
'send',
'session',
'shutdown',
'status',
'stop',
'update'
] as const
export function getPosixPrimeAgentShellWrapper(): string {
const subcommands = PRIME_AGENT_SUBCOMMANDS.join('|')
const guestEndpointPath = wslHookRelayEndpointFilePath('${HOME%/}', '${ORCA_WSL_HOOK_INSTANCE}')
return `# Why: WSL cannot install into Prime's guest-owned config root from the
# Windows host, so pass Orca's status extension only to interactive launches.
if [[ -n "\${ORCA_PRIME_AGENT_STATUS_EXTENSION:-}" ]]; then
__orca_prime_agent_should_skip_extension() {
if [[ "\${1:-}" == "--daemon-socket" && ( "\${3:-}" == "stop" || "\${3:-}" == "rename" ) ]]; then
return 0
fi
if [[ "\${1:-}" == --daemon-socket=* && ( "\${2:-}" == "stop" || "\${2:-}" == "rename" ) ]]; then
return 0
fi
case "\${1:-}" in
help|--help|-h|--version|-v|--extension|--extension=*) return 0 ;;
${subcommands}) return 0 ;;
esac
return 1
}
__orca_prime_agent_has_explicit_extension() {
local __orca_arg
for __orca_arg in "$@"; do
case "$__orca_arg" in
--extension|--extension=*) return 0 ;;
esac
done
return 1
}
__orca_prime_agent() {
if [[ "\${1:-}" == "attach" && ( -z "\${2:-}" || "\${2:-}" == -* ) ]]; then
command prime-agent "$@"
return
fi
if ! __orca_prime_agent_should_skip_extension "$@" && ! __orca_prime_agent_has_explicit_extension "$@" && [[ -f "\${ORCA_PRIME_AGENT_STATUS_EXTENSION}" ]]; then
local __orca_guest_endpoint="${guestEndpointPath}"
if [[ -n "\${HOME:-}" && -n "\${ORCA_WSL_HOOK_INSTANCE:-}" ]]; then
local ORCA_AGENT_HOOK_ENDPOINT="$__orca_guest_endpoint"
export ORCA_AGENT_HOOK_ENDPOINT
fi
if [[ "\${1:-}" == "attach" && -n "\${2:-}" && "\${2:-}" != -* ]]; then
local __orca_attach_agent="$2"
shift 2
command prime-agent attach "$__orca_attach_agent" --extension "\${ORCA_PRIME_AGENT_STATUS_EXTENSION}" "$@"
else
command prime-agent --extension "\${ORCA_PRIME_AGENT_STATUS_EXTENSION}" "$@"
fi
else
command prime-agent "$@"
fi
}
prime-agent() { __orca_prime_agent "$@"; }
fi
`
}
+5
View File
@@ -48,6 +48,8 @@ describe('addOrcaWslInteropEnv', () => {
ORCA_USER_DATA_PATH: 'C:\\Users\\jin\\AppData\\Roaming\\Orca',
ORCA_CLI_COMMAND: 'orca-ide',
ORCA_OMP_STATUS_EXTENSION: 'C:\\Users\\jin\\.omp\\agent\\extensions\\orca-agent-status.ts',
ORCA_PRIME_AGENT_STATUS_EXTENSION:
'C:\\Users\\jin\\AppData\\Roaming\\Orca\\prime-agent-managed-status-extension\\orca-agent-status.ts',
ORCA_PANE_KEY: 'tab-1:leaf-1',
ORCA_TAB_ID: 'tab-1',
ORCA_WORKTREE_ID: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo',
@@ -56,6 +58,7 @@ describe('addOrcaWslInteropEnv', () => {
ORCA_AGENT_HOOK_TOKEN: 'token',
ORCA_AGENT_HOOK_ENV: 'dev',
ORCA_AGENT_HOOK_VERSION: '1',
ORCA_WSL_HOOK_INSTANCE: 'testinstance',
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl',
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local',
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'Ubuntu'
@@ -67,6 +70,7 @@ describe('addOrcaWslInteropEnv', () => {
expect(env.WSLENV).toContain('ORCA_USER_DATA_PATH/p')
expect(env.WSLENV).toContain('ORCA_CLI_COMMAND/u')
expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p')
expect(env.WSLENV).toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION/p')
expect(env.WSLENV).toContain('ORCA_PANE_KEY/u')
expect(env.WSLENV).toContain('ORCA_TAB_ID/u')
expect(env.WSLENV).toContain('ORCA_WORKTREE_ID/u')
@@ -75,6 +79,7 @@ describe('addOrcaWslInteropEnv', () => {
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_TOKEN/u')
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_ENV/u')
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_VERSION/u')
expect(env.WSLENV).toContain('ORCA_WSL_HOOK_INSTANCE/u')
expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u')
expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID/u')
expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION/u')
+1
View File
@@ -96,6 +96,7 @@ export function addOrcaWslInteropEnv(env: Record<string, string>): void {
'ORCA_WSL_HOOK_INSTANCE/u',
'ORCA_OMP_SOURCE_AGENT_DIR/p',
'ORCA_OMP_STATUS_EXTENSION/p',
'ORCA_PRIME_AGENT_STATUS_EXTENSION/p',
...worktreeSetupWslenvEntries(env)
]
applyWslenvPassthrough(env, passthroughEntries)
@@ -1,179 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getDefaultSettings } from '../../shared/constants'
import type { GlobalSettings } from '../../shared/types'
import { getInitialClaudeRateLimitTarget } from './claude-rate-limit-target'
function legacySettingsWithoutAccountRuntime(settings: GlobalSettings): GlobalSettings {
const next = { ...settings } as Partial<GlobalSettings>
delete next.localAccountRuntime
return next as GlobalSettings
}
describe('getInitialClaudeRateLimitTarget', () => {
it('uses the configured WSL account runtime before agent detection runtime', () => {
expect(
getInitialClaudeRateLimitTarget(
{
...getDefaultSettings('/tmp'),
localAccountRuntime: 'wsl',
localAccountWslDistro: 'Fedora',
localAgentRuntime: 'host',
terminalWindowsWslDistro: 'Debian'
},
'win32'
)
).toEqual({ runtime: 'wsl', wslDistro: 'Fedora' })
})
it('uses the single selected WSL account distro when account runtime is WSL default', () => {
expect(
getInitialClaudeRateLimitTarget(
{
...getDefaultSettings('/tmp'),
localAccountRuntime: 'wsl',
activeClaudeManagedAccountIdsByRuntime: {
host: 'host-account-1',
wsl: { Ubuntu: 'wsl-account-1' }
}
},
'win32'
)
).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
})
it('ignores stale terminal WSL distro when account runtime is WSL default', () => {
expect(
getInitialClaudeRateLimitTarget(
{
...getDefaultSettings('/tmp'),
localAccountRuntime: 'wsl',
localAccountWslDistro: null,
terminalWindowsWslDistro: 'Debian',
activeClaudeManagedAccountIdsByRuntime: {
host: 'host-account-1',
wsl: {}
}
},
'win32'
)
).toEqual({ runtime: 'wsl', wslDistro: null })
})
it('uses the global WSL project runtime default when account runtime is unset', () => {
expect(
getInitialClaudeRateLimitTarget(
legacySettingsWithoutAccountRuntime({
...getDefaultSettings('/tmp'),
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' },
localAgentRuntime: 'host',
terminalWindowsWslDistro: 'Debian'
}),
'win32'
)
).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
})
it('ignores stale legacy WSL agent and terminal settings when the project default is host', () => {
expect(
getInitialClaudeRateLimitTarget(
legacySettingsWithoutAccountRuntime({
...getDefaultSettings('/tmp'),
localWindowsRuntimeDefault: { kind: 'windows-host' },
localAgentRuntime: 'wsl',
localAgentWslDistro: 'Ubuntu',
terminalWindowsShell: 'wsl.exe',
terminalWindowsWslDistro: 'Debian'
}),
'win32'
)
).toEqual({ runtime: 'host' })
})
it('uses a single WSL-only active account after restart', () => {
expect(
getInitialClaudeRateLimitTarget(
legacySettingsWithoutAccountRuntime({
...getDefaultSettings('/tmp'),
activeClaudeManagedAccountIdsByRuntime: {
host: null,
wsl: { Ubuntu: 'wsl-account-1' }
}
})
)
).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
})
it('auto follows the global WSL project runtime default', () => {
expect(
getInitialClaudeRateLimitTarget(
{
...getDefaultSettings('/tmp'),
localAccountRuntime: 'auto',
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }
},
'win32'
)
).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
})
it('auto resolves to host when the global project runtime is windows-host', () => {
expect(
getInitialClaudeRateLimitTarget(
{
...getDefaultSettings('/tmp'),
localAccountRuntime: 'auto',
localWindowsRuntimeDefault: { kind: 'windows-host' }
},
'win32'
)
).toEqual({ runtime: 'host' })
})
it('does not let a stale WSL-only selection override an auto host target', () => {
expect(
getInitialClaudeRateLimitTarget(
{
...getDefaultSettings('/tmp'),
localAccountRuntime: 'auto',
localWindowsRuntimeDefault: { kind: 'windows-host' },
activeClaudeManagedAccountIdsByRuntime: {
host: null,
wsl: { Ubuntu: 'wsl-account-1' }
}
},
'win32'
)
).toEqual({ runtime: 'host' })
})
it('keeps explicit host runtime on host', () => {
expect(
getInitialClaudeRateLimitTarget(
{
...getDefaultSettings('/tmp'),
localAccountRuntime: 'host',
localAgentRuntime: 'host',
terminalWindowsShell: 'wsl.exe',
activeClaudeManagedAccountIdsByRuntime: {
host: null,
wsl: { Ubuntu: 'wsl-account-1' }
}
},
'win32'
)
).toEqual({ runtime: 'host' })
})
it('ignores a stale explicit WSL runtime on non-Windows hosts', () => {
expect(
getInitialClaudeRateLimitTarget(
{
...getDefaultSettings('/tmp'),
localAccountRuntime: 'wsl',
localAccountWslDistro: 'Ubuntu'
},
'linux'
)
).toEqual({ runtime: 'host' })
})
})
@@ -1,71 +1,21 @@
import type { GlobalSettings } from '../../shared/types'
import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime'
import {
getClaudeWslSelectionKey,
normalizeClaudeRuntimeSelection,
type ClaudeAccountSelectionTarget
} from '../claude-accounts/runtime-selection'
import {
getProjectRuntimeRateLimitTarget,
normalizeOptionalDistro
} from './project-runtime-rate-limit-target'
function getSingleSelectedWslDistro(settings: GlobalSettings): string | null {
const selection = normalizeClaudeRuntimeSelection(settings)
const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) =>
Boolean(accountId)
)
if (selectedWslEntries.length !== 1) {
return null
}
const [distroKey] = selectedWslEntries[0]
return distroKey === getClaudeWslSelectionKey(null) ? null : distroKey
}
import { getInitialAccountRateLimitTarget } from './initial-account-rate-limit-target'
export function getInitialClaudeRateLimitTarget(
settings: GlobalSettings,
platform: NodeJS.Platform = process.platform
): ClaudeAccountSelectionTarget {
if (settings.localAccountRuntime === 'host') {
return { runtime: 'host' }
}
if (settings.localAccountRuntime === 'wsl') {
if (platform !== 'win32') {
return { runtime: 'host' }
}
return {
runtime: 'wsl',
wslDistro:
normalizeOptionalDistro(settings.localAccountWslDistro) ??
getSingleSelectedWslDistro(settings)
}
}
if (settings.localAccountRuntime === 'auto') {
const target = resolveLocalAccountRuntimeTarget(settings, platform)
return target.runtime === 'wsl'
? { runtime: 'wsl', wslDistro: target.wslDistro }
: { runtime: 'host' }
}
// Why: pre-setting profiles used account selection as their startup fallback.
const projectRuntimeTarget = getProjectRuntimeRateLimitTarget(settings, platform)
if (projectRuntimeTarget) {
return projectRuntimeTarget
}
const selection = normalizeClaudeRuntimeSelection(settings)
if (!selection.host) {
const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) =>
Boolean(accountId)
)
if (selectedWslEntries.length === 1) {
const [distroKey] = selectedWslEntries[0]
return {
runtime: 'wsl',
wslDistro: distroKey === getClaudeWslSelectionKey(null) ? null : distroKey
}
}
}
return { runtime: 'host' }
return getInitialAccountRateLimitTarget(
settings,
{
getWslSelectionKey: getClaudeWslSelectionKey,
normalizeRuntimeSelection: normalizeClaudeRuntimeSelection
},
platform
)
}
@@ -1,179 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getDefaultSettings } from '../../shared/constants'
import type { GlobalSettings } from '../../shared/types'
import { getInitialCodexRateLimitTarget } from './codex-rate-limit-target'
function legacySettingsWithoutAccountRuntime(settings: GlobalSettings): GlobalSettings {
const next = { ...settings } as Partial<GlobalSettings>
delete next.localAccountRuntime
return next as GlobalSettings
}
describe('getInitialCodexRateLimitTarget', () => {
it('uses the configured WSL account runtime before agent detection runtime', () => {
expect(
getInitialCodexRateLimitTarget(
{
...getDefaultSettings('/tmp'),
localAccountRuntime: 'wsl',
localAccountWslDistro: 'Fedora',
localAgentRuntime: 'host',
terminalWindowsWslDistro: 'Debian'
},
'win32'
)
).toEqual({ runtime: 'wsl', wslDistro: 'Fedora' })
})
it('uses the single selected WSL account distro when account runtime is WSL default', () => {
expect(
getInitialCodexRateLimitTarget(
{
...getDefaultSettings('/tmp'),
localAccountRuntime: 'wsl',
activeCodexManagedAccountIdsByRuntime: {
host: 'host-account-1',
wsl: { Ubuntu: 'wsl-account-1' }
}
},
'win32'
)
).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
})
it('ignores stale terminal WSL distro when account runtime is WSL default', () => {
expect(
getInitialCodexRateLimitTarget(
{
...getDefaultSettings('/tmp'),
localAccountRuntime: 'wsl',
localAccountWslDistro: null,
terminalWindowsWslDistro: 'Debian',
activeCodexManagedAccountIdsByRuntime: {
host: 'host-account-1',
wsl: {}
}
},
'win32'
)
).toEqual({ runtime: 'wsl', wslDistro: null })
})
it('uses the global WSL project runtime default when account runtime is unset', () => {
expect(
getInitialCodexRateLimitTarget(
legacySettingsWithoutAccountRuntime({
...getDefaultSettings('/tmp'),
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' },
localAgentRuntime: 'host',
terminalWindowsWslDistro: 'Debian'
}),
'win32'
)
).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
})
it('ignores stale legacy WSL agent and terminal settings when the project default is host', () => {
expect(
getInitialCodexRateLimitTarget(
legacySettingsWithoutAccountRuntime({
...getDefaultSettings('/tmp'),
localWindowsRuntimeDefault: { kind: 'windows-host' },
localAgentRuntime: 'wsl',
localAgentWslDistro: 'Ubuntu',
terminalWindowsShell: 'wsl.exe',
terminalWindowsWslDistro: 'Debian'
}),
'win32'
)
).toEqual({ runtime: 'host' })
})
it('uses a single WSL-only active account after restart', () => {
expect(
getInitialCodexRateLimitTarget(
legacySettingsWithoutAccountRuntime({
...getDefaultSettings('/tmp'),
activeCodexManagedAccountIdsByRuntime: {
host: null,
wsl: { Ubuntu: 'wsl-account-1' }
}
})
)
).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
})
it('auto follows the global WSL project runtime default', () => {
expect(
getInitialCodexRateLimitTarget(
{
...getDefaultSettings('/tmp'),
localAccountRuntime: 'auto',
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }
},
'win32'
)
).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
})
it('auto resolves to host when the global project runtime is windows-host', () => {
expect(
getInitialCodexRateLimitTarget(
{
...getDefaultSettings('/tmp'),
localAccountRuntime: 'auto',
localWindowsRuntimeDefault: { kind: 'windows-host' }
},
'win32'
)
).toEqual({ runtime: 'host' })
})
it('does not let a stale WSL-only selection override an auto host target', () => {
expect(
getInitialCodexRateLimitTarget(
{
...getDefaultSettings('/tmp'),
localAccountRuntime: 'auto',
localWindowsRuntimeDefault: { kind: 'windows-host' },
activeCodexManagedAccountIdsByRuntime: {
host: null,
wsl: { Ubuntu: 'wsl-account-1' }
}
},
'win32'
)
).toEqual({ runtime: 'host' })
})
it('keeps explicit host runtime on host', () => {
expect(
getInitialCodexRateLimitTarget(
{
...getDefaultSettings('/tmp'),
localAccountRuntime: 'host',
localAgentRuntime: 'host',
terminalWindowsShell: 'wsl.exe',
activeCodexManagedAccountIdsByRuntime: {
host: null,
wsl: { Ubuntu: 'wsl-account-1' }
}
},
'win32'
)
).toEqual({ runtime: 'host' })
})
it('ignores a stale explicit WSL runtime on non-Windows hosts', () => {
expect(
getInitialCodexRateLimitTarget(
{
...getDefaultSettings('/tmp'),
localAccountRuntime: 'wsl',
localAccountWslDistro: 'Ubuntu'
},
'darwin'
)
).toEqual({ runtime: 'host' })
})
})
@@ -1,73 +1,18 @@
import type { GlobalSettings } from '../../shared/types'
import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime'
import {
getWslSelectionKey,
normalizeCodexRuntimeSelection,
type CodexAccountSelectionTarget
} from '../codex-accounts/runtime-selection'
import {
getProjectRuntimeRateLimitTarget,
normalizeOptionalDistro
} from './project-runtime-rate-limit-target'
function getSingleSelectedWslDistro(settings: GlobalSettings): string | null {
const selection = normalizeCodexRuntimeSelection(settings)
const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) =>
Boolean(accountId)
)
if (selectedWslEntries.length !== 1) {
return null
}
const [distroKey] = selectedWslEntries[0]
return distroKey === getWslSelectionKey(null) ? null : distroKey
}
import { getInitialAccountRateLimitTarget } from './initial-account-rate-limit-target'
export function getInitialCodexRateLimitTarget(
settings: GlobalSettings,
platform: NodeJS.Platform = process.platform
): CodexAccountSelectionTarget {
if (settings.localAccountRuntime === 'host') {
return { runtime: 'host' }
}
if (settings.localAccountRuntime === 'wsl') {
if (platform !== 'win32') {
return { runtime: 'host' }
}
return {
runtime: 'wsl',
wslDistro:
normalizeOptionalDistro(settings.localAccountWslDistro) ??
getSingleSelectedWslDistro(settings)
}
}
if (settings.localAccountRuntime === 'auto') {
const target = resolveLocalAccountRuntimeTarget(settings, platform)
return target.runtime === 'wsl'
? { runtime: 'wsl', wslDistro: target.wslDistro }
: { runtime: 'host' }
}
// Why: pre-setting profiles used account selection as their startup fallback.
const projectRuntimeTarget = getProjectRuntimeRateLimitTarget(settings, platform)
if (projectRuntimeTarget) {
return projectRuntimeTarget
}
const selection = normalizeCodexRuntimeSelection(settings)
if (!selection.host) {
const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) =>
Boolean(accountId)
)
if (selectedWslEntries.length === 1) {
const [distroKey] = selectedWslEntries[0]
// Why: after restart there is no last-clicked switcher target, but a
// single WSL-only active account is the least surprising quota context.
return {
runtime: 'wsl',
wslDistro: distroKey === getWslSelectionKey(null) ? null : distroKey
}
}
}
return { runtime: 'host' }
return getInitialAccountRateLimitTarget(
settings,
{ getWslSelectionKey, normalizeRuntimeSelection: normalizeCodexRuntimeSelection },
platform
)
}
@@ -0,0 +1,163 @@
import { describe, expect, it } from 'vitest'
import { getDefaultSettings } from '../../shared/constants'
import type { GlobalSettings } from '../../shared/types'
import { getInitialClaudeRateLimitTarget } from './claude-rate-limit-target'
import { getInitialCodexRateLimitTarget } from './codex-rate-limit-target'
type RuntimeSelection = {
host: string | null
wsl: Record<string, string | null>
}
type Scenario = {
name: string
settings: Partial<GlobalSettings>
expected: { runtime: 'host' } | { runtime: 'wsl'; wslDistro: string | null }
legacy?: boolean
platform?: NodeJS.Platform
selection?: RuntimeSelection
}
const providers = [
{
name: 'Codex',
getTarget: getInitialCodexRateLimitTarget,
selectionSettings: (selection: RuntimeSelection) => ({
activeCodexManagedAccountIdsByRuntime: selection
})
},
{
name: 'Claude',
getTarget: getInitialClaudeRateLimitTarget,
selectionSettings: (selection: RuntimeSelection) => ({
activeClaudeManagedAccountIdsByRuntime: selection
})
}
] as const
const scenarios: Scenario[] = [
{
name: 'uses the trimmed configured WSL distro before the selected account',
settings: {
localAccountRuntime: 'wsl',
localAccountWslDistro: ' Fedora ',
localAgentRuntime: 'host',
terminalWindowsWslDistro: 'Debian'
},
selection: { host: null, wsl: { Ubuntu: 'wsl-account-1' } },
expected: { runtime: 'wsl', wslDistro: 'Fedora' }
},
{
name: 'uses the single selected distro for an unpinned WSL runtime',
settings: { localAccountRuntime: 'wsl' },
selection: { host: 'host-account-1', wsl: { Ubuntu: 'wsl-account-1' } },
expected: { runtime: 'wsl', wslDistro: 'Ubuntu' }
},
{
name: 'ignores a stale terminal distro for an unpinned WSL runtime',
settings: {
localAccountRuntime: 'wsl',
localAccountWslDistro: null,
terminalWindowsWslDistro: 'Debian'
},
selection: { host: 'host-account-1', wsl: {} },
expected: { runtime: 'wsl', wslDistro: null }
},
{
name: 'keeps an explicit host runtime on host',
settings: { localAccountRuntime: 'host', terminalWindowsShell: 'wsl.exe' },
selection: { host: null, wsl: { Ubuntu: 'wsl-account-1' } },
expected: { runtime: 'host' }
},
{
name: 'ignores an explicit WSL runtime on non-Windows hosts',
settings: { localAccountRuntime: 'wsl', localAccountWslDistro: 'Ubuntu' },
platform: 'linux',
expected: { runtime: 'host' }
},
{
name: 'auto follows the global WSL runtime before stale selections',
settings: {
localAccountRuntime: 'auto',
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }
},
selection: { host: null, wsl: { Debian: 'wsl-account-1' } },
expected: { runtime: 'wsl', wslDistro: 'Ubuntu' }
},
{
name: 'auto keeps the global host runtime before stale selections',
settings: {
localAccountRuntime: 'auto',
localWindowsRuntimeDefault: { kind: 'windows-host' }
},
selection: { host: null, wsl: { Ubuntu: 'wsl-account-1' } },
expected: { runtime: 'host' }
},
{
name: 'legacy settings use the global WSL runtime before selections',
settings: { localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } },
selection: { host: null, wsl: { Debian: 'wsl-account-1' } },
legacy: true,
expected: { runtime: 'wsl', wslDistro: 'Ubuntu' }
},
{
name: 'legacy settings ignore stale agent and terminal WSL values',
settings: {
localWindowsRuntimeDefault: { kind: 'windows-host' },
localAgentRuntime: 'wsl',
localAgentWslDistro: 'Ubuntu',
terminalWindowsShell: 'wsl.exe',
terminalWindowsWslDistro: 'Debian'
},
legacy: true,
expected: { runtime: 'host' }
},
{
name: 'legacy settings preserve a single WSL selection on macOS',
settings: {},
selection: { host: null, wsl: { Ubuntu: 'wsl-account-1' } },
legacy: true,
platform: 'darwin',
expected: { runtime: 'wsl', wslDistro: 'Ubuntu' }
},
{
name: 'legacy settings map the provider default WSL key to no distro',
settings: {},
selection: { host: null, wsl: { __default__: 'wsl-account-1' } },
legacy: true,
platform: 'linux',
expected: { runtime: 'wsl', wslDistro: null }
},
{
name: 'legacy selection fallback requires no selected host account',
settings: {},
selection: { host: 'host-account-1', wsl: { Ubuntu: 'wsl-account-1' } },
legacy: true,
expected: { runtime: 'host' }
},
{
name: 'legacy selection fallback requires exactly one selected WSL account',
settings: {},
selection: {
host: null,
wsl: { Debian: 'wsl-account-1', Fedora: null, Ubuntu: 'wsl-account-2' }
},
legacy: true,
expected: { runtime: 'host' }
}
]
describe.each(providers)('$name initial rate-limit target', ({ getTarget, selectionSettings }) => {
it.each(scenarios)('$name', (scenario) => {
const settings = {
...getDefaultSettings('/tmp'),
...scenario.settings,
...(scenario.selection ? selectionSettings(scenario.selection) : {})
}
if (scenario.legacy) {
delete (settings as Partial<GlobalSettings>).localAccountRuntime
}
expect(getTarget(settings, scenario.platform ?? 'win32')).toEqual(scenario.expected)
})
})
@@ -0,0 +1,80 @@
import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime'
import type { GlobalSettings } from '../../shared/types'
type AccountRuntimeSelection = {
host: string | null
wsl: Record<string, string | null>
}
type AccountRuntimeSelectionProvider = {
getWslSelectionKey: (wslDistro: string | null | undefined) => string
normalizeRuntimeSelection: (settings: GlobalSettings) => AccountRuntimeSelection
}
type WslRateLimitRuntimeTarget = { runtime: 'wsl'; wslDistro: string | null }
export type AccountRateLimitRuntimeTarget = { runtime: 'host' } | WslRateLimitRuntimeTarget
export function getInitialAccountRateLimitTarget(
settings: GlobalSettings,
provider: AccountRuntimeSelectionProvider,
platform: NodeJS.Platform = process.platform
): AccountRateLimitRuntimeTarget {
if (settings.localAccountRuntime === 'host') {
return { runtime: 'host' }
}
if (settings.localAccountRuntime === 'wsl') {
if (platform !== 'win32') {
return { runtime: 'host' }
}
const configuredTarget = resolveLocalAccountRuntimeTarget(settings, platform)
return {
runtime: 'wsl',
wslDistro:
configuredTarget.wslDistro ??
getSingleSelectedWslTarget(
provider.normalizeRuntimeSelection(settings),
provider.getWslSelectionKey
)?.wslDistro ??
null
}
}
// Pre-setting profiles omit the policy and fall back to project/account selection.
const resolvedTarget = toRateLimitTarget(resolveLocalAccountRuntimeTarget(settings, platform))
if (settings.localAccountRuntime === 'auto' || resolvedTarget.runtime === 'wsl') {
return resolvedTarget
}
const selection = provider.normalizeRuntimeSelection(settings)
return selection.host
? { runtime: 'host' }
: (getSingleSelectedWslTarget(selection, provider.getWslSelectionKey) ?? { runtime: 'host' })
}
function getSingleSelectedWslTarget(
selection: AccountRuntimeSelection,
getWslSelectionKey: AccountRuntimeSelectionProvider['getWslSelectionKey']
): WslRateLimitRuntimeTarget | null {
const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) =>
Boolean(accountId)
)
if (selectedWslEntries.length !== 1) {
return null
}
const [distroKey] = selectedWslEntries[0]
return {
runtime: 'wsl',
wslDistro: distroKey === getWslSelectionKey(null) ? null : distroKey
}
}
function toRateLimitTarget(
target: ReturnType<typeof resolveLocalAccountRuntimeTarget>
): AccountRateLimitRuntimeTarget {
return target.runtime === 'wsl'
? { runtime: 'wsl', wslDistro: target.wslDistro }
: { runtime: 'host' }
}
@@ -1,29 +0,0 @@
import type { GlobalSettings } from '../../shared/types'
import { normalizeGlobalWindowsRuntimeDefault } from '../../shared/project-execution-runtime'
export type AccountRateLimitRuntimeTarget =
| { runtime: 'host' }
| { runtime: 'wsl'; wslDistro: string | null }
export function getProjectRuntimeRateLimitTarget(
settings: GlobalSettings,
platform: NodeJS.Platform
): AccountRateLimitRuntimeTarget | null {
if (platform !== 'win32') {
return null
}
const runtimeDefault = normalizeGlobalWindowsRuntimeDefault(settings.localWindowsRuntimeDefault)
if (runtimeDefault.kind !== 'wsl') {
return null
}
// Why: account quota polling has no project id, so its best default is the
// global project runtime instead of stale terminal/agent WSL settings.
return { runtime: 'wsl', wslDistro: runtimeDefault.distro }
}
export function normalizeOptionalDistro(value: string | null | undefined): string | null {
const trimmed = value?.trim()
return trimmed ? trimmed : null
}
+21
View File
@@ -228,6 +228,7 @@ import type {
GitHubPullRequestStateUpdate,
GitHubPRFile,
GitHubPRReviewCommentInput,
GitHubReactionContent,
GitLabIssueUpdate,
GitLabMRInlineCommentInput,
GitLabProjectRef,
@@ -591,6 +592,7 @@ import {
getPRCheckDetails,
rerunPRChecks,
getPRComments,
setPRCommentReaction,
getIssue,
resolveReviewThread,
setPRFileViewed,
@@ -20147,6 +20149,25 @@ export class OrcaRuntimeService {
)
}
async setRepoPRCommentReaction(
repoSelector: string,
reactionSubjectId: string,
content: GitHubReactionContent,
reacted: boolean,
prRepo?: GitHubOwnerRepo | null
): Promise<Awaited<ReturnType<typeof setPRCommentReaction>>> {
const repo = await this.resolveRepoSelector(repoSelector)
return setPRCommentReaction(
repo.path,
reactionSubjectId,
content,
reacted,
repo.connectionId ?? null,
prRepo ?? null,
...this.getLocalGitExecutionOptionArgs(repo)
)
}
async getRepoPRFileContents(
repoSelector: string,
args: {

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