Merge origin/main into brennanb2025/mobile-i18n-audit

This commit is contained in:
Brennan Benson
2026-08-02 18:04:31 -07:00
634 changed files with 31695 additions and 10097 deletions
+2
View File
@@ -10,6 +10,8 @@ body:
value: |
Use this form for Orca bugs. Include enough detail for someone else to reproduce the issue.
Please write your issue in English.
- type: dropdown
id: os
attributes:
@@ -10,6 +10,8 @@ body:
value: |
Use this form for new features or workflow improvements.
Please write your issue in English.
- type: textarea
id: problem
attributes:
+5
View File
@@ -2,6 +2,11 @@ name: Other
description: Report another Orca issue type
title: '[Other]: '
body:
- type: markdown
attributes:
value: |
Please write your issue in English.
- type: textarea
id: details
attributes:
+343
View File
@@ -0,0 +1,343 @@
name: Adhoc macOS Dev Build
# Why: lets anyone cut a signed macOS build of an unlanded branch so the team can
# actually run an experimental feature for a few days, instead of reasoning about
# it from a diff. Hourly covers main; this covers everything that is not main yet.
#
# Deliberately narrow scope, same trade as hourly:
# - macOS only. Other platforms keep using RC/stable.
# - No tests, no lint, no e2e. PR CI and release-cut remain the gates.
# - Signed AND notarized, exactly like a release. macOS anchors a notarized
# app's TCC grants on identifier + team rather than on its cdhash, so those
# grants survive an update; an unnotarized build reads as a new client and
# silently loses file access under Documents/Desktop/Downloads.
#
# Artifacts publish to stablyai/orca-adhoc — separate from both orca and
# orca-hourly. Separate from orca because the main repo's releases atom feed
# exposes only its 10 newest entries. Separate from orca-hourly because a build
# from someone's branch must never be picked up by a developer who only meant to
# ride main; the two are different levels of "unvetted".
#
# From the Actions tab: pick this workflow, "Run workflow", leave "Use workflow
# from" on main, and type your branch in the first field. Or from the CLI:
#
# gh workflow run adhoc-mac-build.yml --ref main -f ref=my-branch -f label=wasm-terminal
#
# Leaving the ref field empty builds whatever "Use workflow from" is set to, which
# is what someone who only touched that picker means. Naming the branch explicitly
# is still better: the workflow file is always read from the dispatch ref, so a
# branch carrying a stale copy of this file would otherwise run that copy.
#
# GITHUB_TOKEN is scoped to this repo and cannot publish there, so writes use the
# same GitHub App as hourly, additionally installed on orca-adhoc with
# Contents: Read and write. The secret names below are historical — one App, one
# private key, both dev-channel repos — and rotating it stays a single operation.
# Provision with `bash config/scripts/setup-hourly-release-token.sh`.
on:
workflow_dispatch:
inputs:
ref:
# Why optional: the Actions UI already shows its own "Use workflow from"
# branch picker directly above this field, and picking a branch there is
# what most people will read as "build this". Defaulting to that branch
# makes the obvious action correct. Fill this in only to build a ref other
# than the one the workflow file itself is read from — normally leave the
# picker on main and name your branch here, so a stale copy of this
# workflow on an old branch is not what runs.
description: 'Branch, tag, or SHA to build (default: the branch selected above)'
required: false
default: ''
type: string
label:
description: 'Short name shown in the release title (default: the ref)'
required: false
default: ''
type: string
permissions:
contents: read
concurrency:
# Why keyed on the ref rather than global: two people cutting builds from two
# different branches at the same time is the ordinary case here, and serialising
# them would make each wait out the other's notary queue. Re-dispatching the
# *same* branch still queues, so a push mid-build cannot race itself.
group: adhoc-mac-build-${{ inputs.ref || github.ref_name }}
cancel-in-progress: false
env:
ADHOC_REPO: stablyai/orca-adhoc
# Why age and not a count like hourly: this channel is low-volume and bursty, so
# a count would either hold one week's experiments forever or evict a build
# someone is still running after a busy afternoon. A month is well past the "few
# days" these exist for, and by then the branch has landed or been abandoned.
ADHOC_RETAIN_DAYS: 30
jobs:
build-adhoc-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.
timeout-minutes: 150
env:
NODE_OPTIONS: --max-old-space-size=4096
steps:
- name: Checkout the requested ref
uses: actions/checkout@v6
with:
# Why an input at all rather than just github.ref: the whole point is to
# build code that has not landed, and the workflow definition itself
# always comes from the dispatch ref — naming the branch here instead
# applies main's current copy of this file to an arbitrary branch.
ref: ${{ inputs.ref || github.ref_name }}
fetch-depth: 0
# This job only reads stablyai/orca and never pushes; every write goes
# to the adhoc 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: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Cache electron-builder downloads
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
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 an adhoc build installable over an existing
# Orca, so a missing cert must fail here rather than after a 20-minute build.
- name: Verify macOS signing environment
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 adhoc version
id: adhoc
shell: bash
env:
REF: ${{ inputs.ref || github.ref_name }}
LABEL: ${{ inputs.label }}
run: |
set -euo pipefail
# Why this check: the version script is read from the branch being built,
# not from main, so a branch cut before the adhoc channel landed has no
# copy of it. Say that plainly instead of failing with a module-not-found.
if [[ ! -f config/scripts/adhoc-build-version.mjs ]]; then
echo "::error::$REF has no config/scripts/adhoc-build-version.mjs; rebase it onto a main that has the adhoc channel."
exit 1
fi
echo "head_sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT"
ORCA_ADHOC_LABEL="${LABEL:-$REF}" node config/scripts/adhoc-build-version.mjs \
>"$RUNNER_TEMP/adhoc-identity.txt"
if ! grep -q '^name=' "$RUNNER_TEMP/adhoc-identity.txt"; then
echo "::error::adhoc-build-version.mjs emitted no release name; $REF's copy of the script is out of sync with this workflow."
exit 1
fi
cat "$RUNNER_TEMP/adhoc-identity.txt" >>"$GITHUB_OUTPUT"
- name: Build app
run: pnpm build:release
env:
NODE_OPTIONS: --max-old-space-size=4096
# Why: adhoc 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 branch artifacts.
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
# Why the token is minted here and not at the top: installation tokens live
# one hour, everything before this point writes nothing, and the notary round
# trip inside the publish step can be tens of minutes. Minting after the build
# starts the clock at the first call that actually uses it.
- name: Mint adhoc 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-adhoc
- name: Create adhoc release
id: release
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: v${{ steps.adhoc.outputs.version }}
NAME: ${{ steps.adhoc.outputs.name }}
SHA: ${{ steps.adhoc.outputs.head_sha }}
REF: ${{ inputs.ref || github.ref_name }}
# Via env, not inline `${{ }}`: both land inside a shell string, and an
# expression expanded there is substituted before bash parses the line
# (zizmor: template-injection).
ACTOR: ${{ github.actor }}
run: |
set -euo pipefail
short_sha="${SHA:0:12}"
# 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.
gh release create "$TAG" \
--repo "$ADHOC_REPO" \
--title "$NAME" \
--draft \
--notes "Adhoc macOS dev build of \`$REF\` at commit \`$short_sha\`.
Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA), cut by @$ACTOR.
**Unlanded and unvetted.** This is somebody's branch, not main. No tests
ran. Signed and notarized like a release, so it installs through Orca's
in-app updater and opens without a Gatekeeper prompt — but the branch may
never merge, and this build is deleted after $ADHOC_RETAIN_DAYS days."
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
- name: Publish adhoc macOS artifacts
uses: nick-fields/retry@v4
with:
# Why 45: an attempt is pack + notarize + upload, and the notary queue is
# the unbounded part. Two attempts, because a failed adhoc build has a
# person waiting on it who can simply dispatch again.
timeout_minutes: 45
max_attempts: 2
retry_wait_seconds: 30
command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_ADHOC=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-adhoc.
GH_TOKEN: ${{ steps.app_token.outputs.token }}
ORCA_ADHOC_BUILD_VERSION: ${{ steps.adhoc.outputs.version }}
ORCA_BUILD_COMMIT: ${{ steps.adhoc.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.
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# 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
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
assets="$(gh release view "$TAG" --repo "$ADHOC_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.
for required in latest-mac.yml; do
if ! grep -qx "$required" <<<"$assets"; then
echo "::error::Adhoc draft $TAG is missing $required; the updater could not install it."
exit 1
fi
done
if ! grep -q '\.zip$' <<<"$assets"; then
echo "::error::Adhoc 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
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
NAME: ${{ steps.adhoc.outputs.name }}
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.
gh release edit "$TAG" --repo "$ADHOC_REPO" --draft=false --prerelease --title "$NAME"
echo "Published $TAG as \"$NAME\""
# Why: a draft left behind by a failed publish is invisible to users but still
# holds its tag name. Gated on publish_live not having succeeded so a later
# failure (the prune step) cannot delete a release that already went live and
# that people may already be installing. 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:
GH_TOKEN: ${{ steps.app_token.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 "$ADHOC_REPO" --yes ||
echo "::warning::Could not discard draft $TAG; remove it manually."
- name: Prune expired adhoc releases
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: |
set -euo pipefail
# Why compute the cutoff in bash rather than with jq's `now`: this runs
# once per dispatch, and a fixed epoch makes the threshold visible in the
# log when someone asks where their build went.
cutoff=$(( $(date -u +%s) - ADHOC_RETAIN_DAYS * 86400 ))
echo "Pruning adhoc releases created before $(date -u -r "$cutoff" '+%Y-%m-%dT%H:%M:%SZ')"
# --cleanup-tag so pruning does not leave orphan tags with no release or
# assets attached. Drafts are excluded: a stale draft is the failure
# path's business, not the retention window's.
stale="$(gh release list --repo "$ADHOC_REPO" --limit 200 --json tagName,createdAt,isDraft \
--jq "map(select(.isDraft | not)) | map(select((.createdAt | fromdateiso8601) < $cutoff)) | .[].tagName")"
if [[ -z "$stale" ]]; then
echo "Nothing to prune."
exit 0
fi
while read -r tag; do
[[ -n "$tag" ]] || continue
echo "Pruning $tag"
gh release delete "$tag" --repo "$ADHOC_REPO" --yes --cleanup-tag || \
echo "::warning::Could not prune $tag"
done <<<"$stale"
+52 -24
View File
@@ -7,9 +7,11 @@ name: Hourly macOS Dev Build
# - 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 but NOT notarized. Squirrel.Mac validates the replacement bundle's
# signature, not its notarization, so in-place updates still work while the
# ~10min notary round trip is skipped 24x a day.
# - 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 hourly reads as a new client and silently loses file access
# under Documents/Desktop/Downloads — 24 times a day.
#
# Artifacts publish to stablyai/orca-hourly, never to stablyai/orca: the main
# repo's releases atom feed exposes only its 10 newest entries, so 24 hourly
@@ -24,10 +26,14 @@ name: Hourly macOS Dev Build
# HOURLY_RELEASE_APP_ID the App's numeric id
# HOURLY_RELEASE_APP_PRIVATE_KEY the App's .pem private key
#
# Installation tokens live one hour, which is ample here: this job skips tests,
# notarization, and Windows signing entirely, so a run is pack + upload and lands
# well inside that. If one ever did overrun, the release is still an unpublished
# draft at that point, so nothing user-visible breaks.
# Installation tokens live one hour, which is why this mints twice. 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`.
# A pathological retry can still outrun the second token, but the release is an
# unpublished draft until the manifest check passes, so the damage is a stranded
# invisible draft — and the build-number query counts drafts, so it holds its
# number and the next run does not reuse it.
on:
schedule:
@@ -57,11 +63,11 @@ jobs:
build-hourly-mac:
if: github.repository == 'stablyai/orca'
runs-on: blacksmith-6vcpu-macos-15
# Why 120: it must exceed the worst case the retry budgets below can produce
# (install 3x10 + publish 2x25 = 80, plus ~30 for checkout/build/verify), or
# 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 — there is no notarization, Windows signing, or test phase.
timeout-minutes: 120
# 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:
@@ -212,12 +218,27 @@ jobs:
# 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 hourly 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 hourly 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-hourly
- name: Create hourly release
id: release
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
TAG: v${{ steps.hourly.outputs.version }}
NAME: ${{ steps.hourly.outputs.name }}
SHA: ${{ steps.freshness.outputs.head_sha }}
@@ -244,30 +265,37 @@ jobs:
Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA).
**Unvetted.** No tests ran. Signed but not notarized — installable
through Orca's in-app updater, but a manual download will be
Gatekeeper-quarantined."
**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."
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
- name: Publish hourly macOS artifacts
if: steps.freshness.outputs.should_build == 'true'
uses: nick-fields/retry@v4
with:
# Why lower than the release pipeline's 3x45: that budget is sized for
# notarization and Windows signing, neither of which runs here. An attempt
# is pack + upload only.
timeout_minutes: 25
# 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 hourly costs an hour, and the next cron picks the same
# commit up, so a third attempt buys less than it costs in runner time.
timeout_minutes: 45
max_attempts: 2
retry_wait_seconds: 30
command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_HOURLY=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-hourly.
GH_TOKEN: ${{ steps.app_token.outputs.token }}
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
ORCA_HOURLY_BUILD_VERSION: ${{ steps.hourly.outputs.version }}
ORCA_BUILD_COMMIT: ${{ steps.hourly.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: the updater resolves a tag, then fetches latest-mac.yml from it. A
@@ -277,7 +305,7 @@ jobs:
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
@@ -307,7 +335,7 @@ jobs:
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
NAME: ${{ steps.hourly.outputs.name }}
run: |
@@ -333,7 +361,7 @@ jobs:
steps.publish_live.outcome != 'success'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
set -uo pipefail
@@ -346,7 +374,7 @@ jobs:
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
run: |
set -euo pipefail
# Why: --cleanup-tag so pruning does not leave orphan tags behind that
+2
View File
@@ -27,6 +27,8 @@
"app-store-performance/no-identity-selector": "error",
"app-store-performance/no-fresh-selector-result": "error",
"no-fallthrough": "error",
"eslint/no-useless-call": "error",
"oxc/no-accumulating-spread": "error",
"quadratic-buffer-concat/no-loop-carried-concat": "error",
"react/jsx-no-duplicate-props": "error",
"react/jsx-no-undef": "error",
+1 -1
View File
@@ -5,7 +5,7 @@ All UI work — layout, color, typography, spacing, component selection, UX beha
# Style
## Concise/Brief Non-obviosu comments ONLY
* DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW")
* DO: BE CONCISE. 1 LINE if possible
* BE CONCISE. 1 LINE if possible
## Lint Rules: Do Not Disable Max Lines
+28 -17
View File
@@ -16,14 +16,25 @@ const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibilit
const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs')
const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs')
// Why: hourly dev builds must carry the *release* identity — same bundle id and
// Developer ID signature — or Squirrel.Mac refuses to swap them over an installed
// Orca. Only notarization is skipped, which in-place updates never check.
// Why: dev-channel builds must carry the *release* identity — same bundle id,
// 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 isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly
const isMacAdhoc = process.env.ORCA_MAC_ADHOC === '1'
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacAdhoc
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION
const hourlyBuildVersion = isMacHourly ? process.env.ORCA_HOURLY_BUILD_VERSION : undefined
const devChannelBuildVersion = isMacHourly
? process.env.ORCA_HOURLY_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
const appId = 'com.stablyai.orca'
const featureWallResources = {
from: 'resources/onboarding/feature-wall',
@@ -70,8 +81,8 @@ const winSpeechNativeResource = {
module.exports = {
appId,
productName: 'Orca',
...(hourlyBuildVersion
? { extraMetadata: { version: hourlyBuildVersion } }
...(devChannelBuildVersion
? { extraMetadata: { version: devChannelBuildVersion } }
: localBuildVersion
? { extraMetadata: { version: localBuildVersion } }
: {}),
@@ -330,11 +341,14 @@ module.exports = {
// explicit release path so production artifacts remain strict while dev
// artifacts do not fail with broken ad-hoc launch behavior.
hardenedRuntime: isMacRelease,
// Why: Squirrel.Mac validates the replacement bundle's signature, not its
// notarization, so hourly builds stay installable while skipping the ~10min
// notary round trip 24x a day. A manually-downloaded hourly zip will be
// Gatekeeper-quarantined — that is the accepted tradeoff for a dev channel.
notarize: isMacRelease && !isMacHourly,
// Why dev builds notarize too, despite the ~10min notary round trip: TCC
// anchors a notarized Developer ID app's permission grants on identifier +
// team, which is cdhash-independent and so survives an update. Without a
// ticket there is no such stable identity, so every build reads as a
// different client — the grant row stays but stops matching, and file access
// under Documents/Desktop/Downloads fails with EPERM and no re-prompt. At 24
// builds a day that revokes the user's grants faster than they can re-grant.
notarize: isMacRelease,
extraResources: [
...commonExtraResources,
...createPackagedRuntimeNodeModuleResources('darwin'),
@@ -474,11 +488,8 @@ module.exports = {
publish: {
provider: 'github',
owner: 'stablyai',
// Why: hourly tags must never enter the main repo's releases atom feed — it
// 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.
repo: isMacHourly ? 'orca-hourly' : 'orca',
releaseType: isMacHourly ? 'prerelease' : 'release'
repo: devChannelRepo ?? 'orca',
releaseType: devChannelRepo ? 'prerelease' : 'release'
}
}
+41
View File
@@ -0,0 +1,41 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": [
"src/main/index.ts",
"src/preload/index.ts",
"src/preload/browser-window-close.ts",
"src/main/daemon/daemon-entry.ts",
"src/main/plugins/plugin-host-entry.ts",
"src/main/computer/sidecar-entry.ts",
"src/main/speech/stt-worker.ts",
"src/main/warp-themes/warp-theme-parser-worker.ts",
"src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts",
"src/main/ipc/parcel-watcher-process-entry.ts",
"src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts",
"src/main/codex/codex-app-server-grant-entry.ts",
"src/main/agent-hooks/managed-agent-hook-controls.ts",
"src/main/claude-accounts/keychain.ts",
"src/renderer/src/main.tsx",
"src/renderer/src/popout.tsx",
"src/renderer/src/web/main.tsx",
"src/renderer/src/**/*.worker.ts",
"src/cli/index.ts",
"src/relay/relay.ts",
"src/relay/wsl-agent-hook-relay.ts",
"config/scripts/**/*.{mjs,cjs,js,ts}",
"build-plugins/**/*.ts",
"electron.vite.config.ts",
"vite.web.config.ts",
"config/vitest.config.ts",
"config/i18next.config.ts",
"config/electron-builder.config.cjs",
"config/oxlint-plugins/**/*.{js,mjs,ts}",
"tests/**/*.{ts,tsx,mjs}",
"**/*.test.{ts,tsx}"
],
"project": ["src/**/*.{ts,tsx}", "config/scripts/**/*.{mjs,cjs,js,ts}", "build-plugins/**/*.ts"],
"ignore": ["mobile/**", "out/**", "dist/**", "node_modules/**", "resources/**"],
"ignoreDependencies": ["electron", "@types/*"],
"ignoreExportsUsedInFile": true,
"includeEntryExports": false
}
-1
View File
@@ -2,7 +2,6 @@
# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —
# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").
# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)
inline mobile/src/constants/marine-creatures.ts
inline src/cli/handlers/automations.ts
inline src/cli/handlers/orchestration.ts
inline src/cli/help.ts
+285 -7
View File
@@ -1271,6 +1271,128 @@
],
"demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner."
},
{
"id": "terminal-session.layout-pty-ownership",
"title": "Restored terminal layouts retain one renderer owner per PTY",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-runtime",
"layer": "renderer-electron-restore",
"surfaces": [
"persisted terminal layout restore",
"daemon PTY reattach",
"terminal tab hide and reveal",
"xterm renderer ownership",
"remote terminal layout mirroring"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "local-daemon", "ssh", "wsl", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local-daemon"],
"coverageNotes": "Provider-independent unit tests cover persisted replay, state-boundary authority transfer, remote-runtime mirroring, rootless layouts, repeated leaf ids, metadata repair, and 12,000 nested duplicate leaves. A two-launch macOS Electron journey covers a real surviving local-daemon PTY, persisted duplicate ownership, synchronized streaming output, and tab hide/reveal. Live Linux, Windows, SSH, WSL, and remote-runtime restore journeys remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/11757",
"https://github.com/stablyai/orca/pull/11726"
],
"invariant": "Within one terminal tab, each PTY has at most one layout leaf, pane manager surface, and xterm renderer owner. Restore and remote mirroring must normalize duplicate ownership before replay while retaining active-leaf focus, scrollback, pane authority, agent metadata, and distinct sibling PTYs.",
"oracle": "Launch Orca with a real daemon PTY running a synchronized full-screen stream, close the desktop client without killing the daemon process, seed the persisted tab layout with two leaves bound to that PTY, and relaunch. Hide the restored tab behind a sibling and reveal it, then require the stream to remain visible with exactly one manager pane, one xterm DOM node, one root leaf, one PTY binding, and one unique PTY. Unit contracts require the same one-owner result through persisted replay, store hydration, remote mirroring, rootless and repeated-leaf layouts, and deeply nested input.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --reporter=dot",
"pnpm exec electron-vite build --mode e2e",
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
],
"testFiles": [
"src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts",
"src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts",
"src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts",
"src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts",
"src/renderer/src/runtime/web-session-tabs-sync.test.ts",
"tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts",
"assertions": [
"replays one surface when restored leaves point to the same PTY",
"reattaches one PTY when the split repeats its bound leaf id"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts",
"assertions": ["prunes deeply nested duplicate ownership without recursive stack growth"]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts",
"assertions": [
"keeps the active leaf and prunes the stale surface plus its metadata",
"is idempotent and preserves one owner across PTY and focus permutations"
]
},
{
"file": "src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts",
"assertions": [
"normalizes duplicate PTY surfaces at the renderer state boundary",
"moves hydrated pane authority onto the retained PTY leaf"
]
},
{
"file": "src/renderer/src/runtime/web-session-tabs-sync.test.ts",
"assertions": ["deduplicates mirrored leaves that claim the same remote PTY"]
},
{
"file": "tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts",
"assertions": ["repairs duplicate persisted PTY renderers before streaming tab reveal"]
}
],
"evidenceRuns": [
{
"date": "2026-08-01",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 1.33,
"summary": "Five focused files passed 92 ownership, replay, hydration, depth, and remote-mirroring tests."
},
{
"date": "2026-08-01",
"runner": "local",
"platform": "macos",
"command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"result": "passed",
"durationSeconds": 8.7,
"summary": "The two-launch Electron journey preserved one real daemon PTY and one renderer through duplicate-layout repair and post-relaunch tab reveal."
}
],
"runtimeBudget": {
"p95Seconds": 30,
"scope": "focused ownership unit suite plus one prebuilt two-launch Electron journey"
},
"flakeHistory": {
"status": "unknown",
"evidence": "The deterministic unit and Electron gates pass locally on macOS; focused CI and soak history are not yet available."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "Orca 1.4.161 visibly strands and fragments the synchronized stream after restoring duplicate PTY renderers (https://github.com/user-attachments/assets/6917ac6f-9ba3-4f6f-a082-fb62932d511f). The post-resize capture remains corrupted, with duplicated lower rows and the terminal still stranded on the right; it is not recovery evidence (https://github.com/user-attachments/assets/32f7be79-0919-48c2-bcad-e4b7f1e14f2d). After the #11726 ownership repair, the equivalent stream remains continuous in one full-width renderer (https://github.com/user-attachments/assets/8c9cc5a2-dbe3-4348-b05c-f8268b98c476), and the final automated journey reports one owner at every layer after relaunch and tab reveal (https://github.com/user-attachments/assets/f19b46aa-e56f-476c-9e2c-e4c3a784c805)."
},
"performanceBudget": {
"required": true,
"evidence": "Ownership normalization is bounded by layout size, uses iterative traversal, and passes a 12,000-leaf duplicate layout without recursive stack growth. The follow-up adds no production polling, renderer work, listeners, persistence scans, or retained payloads."
},
"promotionCriteria": [
"Collect 100 consecutive focused CI passes or 14 days of soak history.",
"Run the live two-launch journey on Linux and Windows.",
"Add live SSH or remote-runtime duplicate-restore evidence before claiming provider-complete coverage."
],
"knownGaps": [
"Live Electron evidence currently covers macOS with a local daemon PTY only.",
"The live journey uses a git-backed test workspace; folder-workspace restoration currently relies on the same provider-independent normalization contracts rather than a separate Electron run.",
"The gate deterministically seeds the historical persisted-state shape; it does not depend on reproducing the unknown UI sequence that originally wrote duplicate ownership.",
"The visual old-release evidence and the structural current-version oracle are separate runs because the current test harness did not exist in Orca 1.4.161."
],
"demotionRule": "Keep experimental or demote if ownership cardinality flakes, duplicate replay reaches a second renderer, active metadata or authority moves to the wrong leaf, deep normalization regresses, or a supported provider bypasses normalization."
},
{
"id": "terminal-session.kill-all-surface-cleanup",
"title": "Kill all sessions removes only the confirmed terminal surfaces and current bindings",
@@ -3567,13 +3689,13 @@
"providers": ["paired-runtime", "ssh"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["paired-runtime"],
"coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, prove sustained output while all six client xterms are warm-mounted but hidden causes zero renderer scheduler work, ordinary-park five xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore the bounded authoritative tail exactly once on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A headed output-drop oracle proves a successful snapshot probe cannot certify a stale live stream when the authoritative PTY sequence advanced beyond the client high-water. Unit coverage requires an unsequenced client to establish a first-probe baseline, remain attached at the same sequence, and recover only after a later probe advances. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, mixed-version paired hosts, live SSH, and production-scale paired hosts remain gaps.",
"coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, prove sustained output while all six client xterms are warm-mounted but hidden causes zero renderer scheduler work, ordinary-park five xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore the bounded authoritative tail exactly once on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A headed output-drop oracle proves a successful snapshot probe cannot certify a stale live stream when the authoritative PTY sequence advanced beyond the client high-water. Unit coverage requires an unsequenced client to establish a first-probe baseline, remain attached at the same sequence, and recover only after a later probe advances. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, 64 simultaneous synthetic-title sources with zero decorative client events after convergence, per-client late-subscribe recovery, semantic bell/title transitions, local animation-frame preservation, legacy no-output-pause delivery gating with exact snapshot restore, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, live SSH, and production-scale paired hosts remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/8652",
"https://github.com/stablyai/orca/pull/10625"
],
"invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Hosts without the capability must retain the existing limit/TTL force-parking fallback. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.",
"oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Drop and acknowledge output only for one original paired-client stream, prove the fixture process consumed input while the host model advanced and the client stayed stale, then require the command snapshot probe to replace that stream, repaint exact fixture output, preserve authoritative PTY identity and target-tab cardinality, and resume live I/O. For a client without a delivered sequence, require the first numeric probe to establish a baseline without replacement, a same-sequence probe to remain attached, and a later advanced probe to replace only that stream. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.",
"invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After a paired client's first semantic title state, decorative spinner frequency must add zero encrypted client-event frames; local title animation and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.",
"oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Drive 64 host PTYs through ten 80 ms synthetic title frames, require zero paired client events after the first frame per PTY while all 640 local frames remain observable, attach a late client and require one current frame per PTY, then require semantic bell and idle transitions on both clients. Reconstruct a legacy subscribed stream with no outputPause capability, hide a chatty pane, require no hidden xterm writes, reveal it, and require one authoritative snapshot plus continued live output. Drop and acknowledge output only for one original paired-client stream, prove the fixture process consumed input while the host model advanced and the client stayed stale, then require the command snapshot probe to replace that stream, repaint exact fixture output, preserve authoritative PTY identity and target-tab cardinality, and resume live I/O. For a client without a delivered sequence, require the first numeric probe to establish a baseline without replacement, a same-sequence probe to remain attached, and a later advanced probe to replace only that stream. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api.test.ts src/main/ipc/runtime-environments.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts",
@@ -3671,6 +3793,7 @@
"file": "src/main/runtime/orca-runtime.test.ts",
"assertions": [
"forwards facts over the shared client-event stream without a desktop renderer",
"bounds decorative title delivery per paired client without reducing local frames",
"prefers provider history over a partial headless mirror for requested snapshots",
"falls back to the available mirror when authoritative provider history is unavailable",
"bounds a hung authoritative provider acquisition and reuses its fallback"
@@ -3690,7 +3813,11 @@
},
{
"file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"assertions": ["restores configured paired scrollback after an ordinary park reveal"]
"assertions": [
"pauses capable paired output while hidden and restores exactly on reveal",
"locally gates hidden paired output when a legacy host cannot pause it",
"restores configured paired scrollback after an ordinary park reveal"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts",
@@ -3739,6 +3866,33 @@
}
],
"evidenceRuns": [
{
"date": "2026-08-02",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api.test.ts src/main/ipc/runtime-environments.test.ts",
"result": "passed",
"durationSeconds": 18.71,
"summary": "Seventeen focused files passed 2,044 tests with one existing skip, including the 64-PTY decorative-title bound, per-client late subscription, semantic-fact preservation, and the true no-output-pause legacy client shape."
},
{
"date": "2026-08-02",
"runner": "local",
"platform": "macos",
"command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
"result": "passed",
"durationSeconds": 13.7,
"summary": "The isolated headed desktop host and encrypted paired web client kept hidden renderer work bounded, ordinary-parked five of six real PTYs, restored the authoritative tail once, preserved PTY identity, and resumed live input/output."
},
{
"date": "2026-08-02",
"runner": "local",
"platform": "macos",
"command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"result": "passed",
"durationSeconds": 31.4,
"summary": "Both isolated headless `orca serve` scenarios passed: ordinary parking restored authoritative history and cold activation mounted only the visible paired terminal."
},
{
"date": "2026-07-30",
"runner": "local",
@@ -3840,11 +3994,11 @@
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. The sequence-gap unit and headed paired oracle are red on current main and with only the new probe decision reverted: the authoritative host advances while the client stays stale after a successful probe. The candidate replaces the stream, repaints the marker, preserves PTY identity, and resumes I/O. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases."
"evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. The 64-PTY decorative-title control delivered 576 client events after convergence on unmodified main; the candidate delivers zero while preserving all 640 local frames, one current frame to a late client, and bell/idle transitions to both clients. The legacy no-output-pause control failed to request a reveal snapshot and kept hidden bytes on the xterm path; the candidate gates those bytes locally and restores one authoritative snapshot before live output. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. The sequence-gap unit and headed paired oracle are red on current main and with only the new probe decision reverted: the authoritative host advances while the client stays stale after a successful probe. The candidate replaces the stream, repaints the marker, preserves PTY identity, and resumes I/O. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases."
},
"performanceBudget": {
"required": true,
"evidence": "Before ordinary parking, six warm-mounted hidden paired xterms receive zero raw frames and schedule zero renderer output drains while host models continue ingesting sustained output. Ordinary parking then retains the host PTY and bounded 5,000-row provider model while destroying five client xterms. Parked side effects share the singleton runtime client-event stream. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. Legacy hosts retain the prior 12-worktree/45-minute lossy force-parking policy. Stream recovery adds no polling and is scoped to one stream."
"evidence": "Before ordinary parking, six warm-mounted hidden paired xterms receive zero raw frames and schedule zero renderer output drains while host models continue ingesting sustained output. Ordinary parking then retains the host PTY and bounded 5,000-row provider model while destroying five client xterms. Decorative title ticks are collapsed per paired subscriber before JSON serialization and encryption; semantic facts remain on the singleton client-event stream. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. Legacy hosts retain the prior 12-worktree/45-minute lossy force-parking policy but now gate hidden raw output before xterm scheduling and restore from a bounded snapshot on reveal. Stream recovery adds no polling and is scoped to one stream."
},
"promotionCriteria": [
"Collect 100 consecutive CI passes or 14 days of soak history for the headed and headless parking commands.",
@@ -3858,9 +4012,10 @@
"The scaled paired scenarios use six worktrees and ordinary-park five; they prove real host-backed eviction and buffer release, not a 100-worktree soak.",
"Display-off reveal latency is a separate atlas and viewport-reflow class and is not covered by this gate.",
"Historical field trace archives are still required to order replay-wedge events against renderer heartbeat loss and deduplicate archived records.",
"The legacy protocol shape is deterministic unit coverage; a packaged pre-v1.4.163 host has not yet run the full headed memory oracle.",
"The live windows-issues incident recorded two fast snapshot-probe successes while fresh output remained stale, but that release did not log the returned snapshot sequence; the deterministic sequence-gap oracle matches the observed false-health boundary without proving the field transport's lower-level drop mechanism."
],
"demotionRule": "Keep experimental or demote to protection none if paired first paint is blank, passive work reconnects after manual disconnect, a capable hidden paired manager survives ordinary parking, a legacy host bypasses its fallback, reveal loses requested bounded history or live PTY identity, retained buffer cells do not fall, or either headed/headless scenario flakes."
"demotionRule": "Keep experimental or demote to protection none if decorative title frequency changes paired client-event volume after convergence, a semantic fact is dropped, paired first paint is blank, passive work reconnects after manual disconnect, a capable hidden paired manager survives ordinary parking, a legacy hidden pane schedules raw xterm output or bypasses its fallback, reveal loses requested bounded history or live PTY identity, retained buffer cells do not fall, or either headed/headless scenario flakes."
},
{
"id": "terminal-performance.no-hot-list-sessions",
@@ -7354,6 +7509,129 @@
],
"demotionRule": "Disable the probe if it exceeds its per-reveal budget or produces false-positive anomaly noise."
},
{
"id": "terminal-render.atlas-recovery-fanout",
"title": "Terminal atlas recovery stays bounded to visible renderers",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-rendering",
"layer": "renderer-unit-and-electron",
"surfaces": [
"WebGL rendering",
"document visibility",
"hidden terminal output",
"paired terminal traffic"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "daemon", "ssh", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local"],
"coverageNotes": "A deterministic renderer contract models one visible manager plus 64 mounted hidden managers and hidden synchronized/TUI output. A macOS Electron run verifies that a document visibility cycle preserves real WebGL atlases and terminal pixels. Production v1.4.163 evidence linked the same 49-manager fanout to paired traffic, but the candidate has not been rerun against an isolated live paired server.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/7054",
"https://github.com/stablyai/orca/pull/7604"
],
"invariant": "Ordinary document visibility transitions and hidden terminal output must not clear the shared WebGL glyph atlas. Heavy reset-and-refresh recovery may touch only managers with visible terminal surfaces; hidden managers recover when revealed. Genuine OS resume remains a heavy recovery trigger.",
"oracle": "Dispatch a visible document visibilitychange and require atlas-preserving wake recovery; register one visible manager and 64 hidden managers and require exactly one reset and one refresh; parse hidden synchronized and high-confidence TUI output and require zero global atlas-recovery schedules. Separately, drive an Electron visibility cycle with two real WebGL panes, require zero atlas clears, and retain at least 85% of each pane's baseline ink pixels.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1"
],
"testFiles": [
"src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts",
"src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts",
"src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts",
"src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts",
"assertions": ["preserves the glyph atlas when a fullscreen Space becomes visible"]
},
{
"file": "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts",
"assertions": [
"preserves WebGL texture atlases when the active terminal document becomes visible"
]
},
{
"file": "src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts",
"assertions": ["bounds atlas recovery to visible managers"]
},
{
"file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"assertions": [
"defers hidden synchronized-output atlas recovery until reveal",
"defers hidden high-confidence TUI redraw recovery until reveal",
"advances hidden rewrite state without scheduling atlas recovery"
]
},
{
"file": "tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts",
"assertions": [
"preserves the WebGL atlas and keeps terminal text painted after document visibility resumes"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-01",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"result": "passed",
"durationSeconds": 26,
"summary": "All 577 focused renderer contracts passed, including atlas-preserving visibility, visible-only recovery fanout, and hidden-output rewrite-state coverage."
},
{
"date": "2026-08-01",
"runner": "local",
"platform": "macos",
"command": "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"result": "passed",
"durationSeconds": 18,
"summary": "The changed-spec CI topology retained both real WebGL terminal panes with zero atlas clears after a deterministic document visibility cycle."
},
{
"date": "2026-08-01",
"runner": "local",
"platform": "macos",
"command": "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
"result": "passed",
"durationSeconds": 23,
"summary": "Two real WebGL terminal panes retained painted glyphs with zero atlas clears after a deterministic document visibility cycle. BrowserWindow.hide did not change document visibility in the harness, so the test used its explicit visibility-event fallback."
}
],
"runtimeBudget": {
"p95Seconds": 60,
"scope": "focused renderer contracts plus one prebuilt Electron visibility test"
},
"flakeHistory": {
"status": "unknown",
"evidence": "The focused unit oracle and one local Electron run passed; CI soak history is not yet available."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "With the fix disabled, the byte-identical unit oracle observed clearGlyphAtlases=true, reset/refreshed all 65 managers, and scheduled hidden synchronized/TUI recovery one to three times. Restoring the fix made every assertion pass."
},
"performanceBudget": {
"required": true,
"evidence": "A recovery with one visible and 64 hidden managers performs one reset and one refresh instead of 65 of each. Hidden synchronized/TUI output schedules zero global recovery work; no polling, cooldown, provider call, or parking change is added."
},
"promotionCriteria": [
"Accumulate stable macOS Electron runs with real BrowserWindow visibility transitions.",
"Run an isolated headed paired-server terminal flood and verify bounded renderer CPU and atlas diagnostics.",
"Add Linux and Windows WebGL visibility evidence before claiming cross-platform visual coverage."
],
"knownGaps": [
"The Electron harness used a deterministic visibility-event fallback because BrowserWindow.hide did not change document.visibilityState.",
"No isolated live paired-server candidate run is recorded; remote-runtime coverage is a provider-agnostic renderer contract plus production incident evidence.",
"The gate counts recovery fanout and pixel retention but does not impose an end-to-end renderer frame-latency threshold."
],
"demotionRule": "Demote if hidden managers re-enter reset/refresh recovery, ordinary visibility clears the atlas, real WebGL pixels regress, or the focused Electron test cannot remain deterministic."
},
{
"id": "terminal-render.atlas-identity-invalidation",
"title": "A WebGL atlas identity change rebuilds cached glyph vertices",
+106
View File
@@ -0,0 +1,106 @@
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs'
/** Long enough to name a feature, short enough that a picker row stays readable. */
export const ADHOC_LABEL_MAX_LENGTH = 32
/**
* `1.4.160-adhoc.20260728140533` — UTC to the second, so tags sort
* chronologically by semver and every build is uniquely versioned.
*
* Why seconds when hourly uses minutes: hourly runs under a concurrency group and
* cannot overlap itself. Adhoc builds are dispatched on demand, so two people
* cutting from different branches in the same minute is ordinary — and a
* minute-resolution tag would collide and fail the second build after its whole
* pack-and-notarize run.
*/
export function createAdhocBuildVersion(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('Adhoc 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()),
pad(date.getUTCSeconds())
].join('')
// Why: drop any -rc.N tail, same as hourly. Keeping it would make every adhoc
// build semver-NEWER than the RC it was cut from, letting an ordinary
// RC-channel check offer an unreviewed branch build to RC users. Stripping to
// the base parks adhoc below rc.N, hourly, and stable ('adhoc' sorts first
// alphabetically), reachable only by an explicit pinned jump.
return `${match[1]}-adhoc.${stamp}`
}
/**
* Turns a dispatch input into a label safe to put in a release title.
*
* The input is free text from whoever ran the workflow, so it cannot be trusted
* to stay inside the title's shape: a stray `•` would forge a field separator,
* and a newline would break the `$GITHUB_OUTPUT` line the workflow parses. Both
* collapse to `-` here, which is why this replaces rather than rejects.
*/
export function normalizeAdhocLabel(label) {
const cleaned = String(label ?? '')
// `refs/heads/x` and `origin/x` are what a ref input tends to arrive as; the
// prefix is noise in a title where every row is already a branch build.
.replace(/^(?:refs\/heads\/|origin\/)/, '')
.replace(/[^\p{L}\p{N}._/-]+/gu, ' ')
.trim()
.replace(/\s+/g, '-')
.slice(0, ADHOC_LABEL_MAX_LENGTH)
// Truncation can land mid-separator, leaving a title ending in `-` or `/`.
.replace(/[-._/]+$/, '')
if (!cleaned) {
throw new Error(`Adhoc label has no usable characters: ${JSON.stringify(label)}`)
}
return cleaned
}
/**
* `1.4.163 • wasm-terminal • 08-01 14:25 • abc1234` — the human-facing release
* title, shown verbatim in both the GitHub releases list and the build picker.
*
* Why the label sits where hourly puts its build number: several adhoc builds
* from different branches coexist in the channel, so the picker needs the branch
* to tell them apart. A counter would say nothing about which one to pick.
*/
export function formatAdhocReleaseName(version, label, commit, date) {
return [
version.split('-')[0],
normalizeAdhocLabel(label),
formatReleaseTitleTimestamp(date),
commit.slice(0, 7)
].join(' • ')
}
export function getAdhocBuildIdentity(now = new Date(), label = '') {
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
encoding: 'utf8'
}).trim()
const version = createAdhocBuildVersion(packageJson.version, now)
return {
commit,
version,
label: normalizeAdhocLabel(label),
name: formatAdhocReleaseName(version, label, commit, now)
}
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
const identity = getAdhocBuildIdentity(new Date(), process.env.ORCA_ADHOC_LABEL ?? '')
// Consumed by the workflow via $GITHUB_OUTPUT.
process.stdout.write(
`version=${identity.version}\ncommit=${identity.commit}\nlabel=${identity.label}\nname=${identity.name}\n`
)
}
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest'
import {
createAdhocBuildVersion,
formatAdhocReleaseName,
normalizeAdhocLabel
} from './adhoc-build-version.mjs'
import { createHourlyBuildVersion } from './hourly-build-version.mjs'
import { compareAppVersions } from '../../src/shared/app-version'
describe('createAdhocBuildVersion', () => {
it('stamps the version with a zero-padded UTC timestamp to the second', () => {
expect(createAdhocBuildVersion('1.4.160', new Date('2026-07-28T04:05:09Z'))).toBe(
'1.4.160-adhoc.20260728040509'
)
})
// Why seconds matter: adhoc builds are dispatched on demand, so two people
// cutting from different branches inside the same minute is ordinary. At
// hourly's resolution the second one would collide on the tag and die after its
// whole pack-and-notarize run.
it('distinguishes two builds cut in the same minute', () => {
const first = createAdhocBuildVersion('1.4.160', new Date('2026-07-28T14:05:02Z'))
const second = createAdhocBuildVersion('1.4.160', new Date('2026-07-28T14:05:41Z'))
expect(first).not.toBe(second)
expect(compareAppVersions(first, second)).toBeLessThan(0)
})
it('drops an in-flight rc tail so adhoc builds never outrank the rc series', () => {
const version = createAdhocBuildVersion('1.4.160-rc.3', new Date('2026-07-28T14:00:00Z'))
expect(version).toBe('1.4.160-adhoc.20260728140000')
expect(compareAppVersions(version, '1.4.160-rc.3')).toBeLessThan(0)
expect(compareAppVersions(version, '1.4.160')).toBeLessThan(0)
})
// Why this ordering is load-bearing: an adhoc build is somebody's unlanded
// branch. It must sit below every other channel of the same base version so no
// routine check can walk a developer onto one — only an explicit pinned jump.
it('sorts below the hourly build of the same base version', () => {
expect(
compareAppVersions(
createAdhocBuildVersion('1.4.160', new Date('2026-07-28T23:59:59Z')),
createHourlyBuildVersion('1.4.160', new Date('2026-07-28T00:00:00Z'))
)
).toBeLessThan(0)
})
it('rejects invalid input', () => {
expect(() => createAdhocBuildVersion('nope', new Date())).toThrow(/valid semver/)
expect(() => createAdhocBuildVersion('1.4.160', new Date('nope'))).toThrow(/invalid/)
})
})
describe('normalizeAdhocLabel', () => {
it('keeps an ordinary branch name intact', () => {
expect(normalizeAdhocLabel('wasm-terminal')).toBe('wasm-terminal')
expect(normalizeAdhocLabel('nwparker/wasm-terminal')).toBe('nwparker/wasm-terminal')
})
it('strips the ref prefixes a dispatch input tends to arrive with', () => {
expect(normalizeAdhocLabel('refs/heads/wasm-terminal')).toBe('wasm-terminal')
expect(normalizeAdhocLabel('origin/wasm-terminal')).toBe('wasm-terminal')
})
// Why replaced rather than rejected: the label is free text from whoever ran
// the workflow. A `•` would forge the title's field separator and a newline
// would break the `$GITHUB_OUTPUT` line the workflow parses.
it('neutralizes characters that would corrupt the title or the output line', () => {
expect(normalizeAdhocLabel('a • b')).toBe('a-b')
expect(normalizeAdhocLabel('a\nname=evil')).toBe('a-name-evil')
expect(normalizeAdhocLabel(' spaced out ')).toBe('spaced-out')
})
it('truncates without leaving a trailing separator', () => {
expect(normalizeAdhocLabel('a'.repeat(80))).toHaveLength(32)
expect(normalizeAdhocLabel(`${'a'.repeat(31)}-tail`)).toBe('a'.repeat(31))
})
it('rejects a label with nothing usable in it', () => {
expect(() => normalizeAdhocLabel('')).toThrow(/no usable characters/)
expect(() => normalizeAdhocLabel(' ')).toThrow(/no usable characters/)
expect(() => normalizeAdhocLabel('•••')).toThrow(/no usable characters/)
expect(() => normalizeAdhocLabel(null)).toThrow(/no usable characters/)
})
})
describe('formatAdhocReleaseName', () => {
const name = (iso, label = 'wasm-terminal', commit = 'e698241abcde') =>
formatAdhocReleaseName('1.4.163-adhoc.x', label, commit, new Date(iso))
it('renders version, label, Pacific timestamp, and short sha', () => {
expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • wasm-terminal • 07-31 13:54 • 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-15T02:30:00Z')).toContain(' 01-14 18:30 ')
expect(name('2026-07-31T07:00:00Z')).toContain(' 07-31 00:00 ')
})
it('sanitizes the label before it reaches the title', () => {
expect(name('2026-07-31T20:54:00Z', 'refs/heads/fix • now')).toBe(
'1.4.163 • fix-now • 07-31 13:54 • e698241'
)
})
it('rejects an invalid timestamp', () => {
expect(() => formatAdhocReleaseName('1.4.163', 'x', 'abcdefg', new Date('nope'))).toThrow(
/invalid/
)
})
})
@@ -21,8 +21,10 @@ const {
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'
]
@@ -51,6 +53,7 @@ function withEnv(env, assert) {
}
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', () => {
@@ -313,15 +316,18 @@ describe('electron-builder config', () => {
})
})
// Why: notarization is the one release step hourly skips; in-place updates never
// check it, and 24 notary round trips a day is the cost being avoided.
it('skips notarization only for hourly builds', () => {
// 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(false)
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.
@@ -346,6 +352,40 @@ describe('electron-builder config', () => {
)
})
// 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)
+2 -24
View File
@@ -1,6 +1,7 @@
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs'
/** `1.4.160-hourly.202607281400` — UTC to the minute, so tags sort chronologically
* by semver and every build is uniquely versioned. */
@@ -28,41 +29,18 @@ export function createHourlyBuildVersion(baseVersion, date) {
return `${match[1]}-hourly.${stamp}`
}
const RELEASE_NAME_TIME_ZONE = 'America/Los_Angeles'
/**
* `1.4.163 • 01 • 07-31 13:54 • e698241` — the human-facing release title, shown
* verbatim in both the GitHub releases list and the in-app build picker.
*
* Why Pacific while the tag's stamp stays UTC: the stamp is a sort key, and a
* local one would repeat an hour at every DST fall-back, making two distinct
* builds compare equal. The title is only ever read, so it uses the timezone the
* people reading it are in. The two therefore disagree by the current offset.
*/
export function formatHourlyReleaseName(version, buildNumber, commit, date) {
if (!Number.isInteger(buildNumber) || buildNumber < 1) {
throw new Error(`Hourly build number must be a positive integer: ${buildNumber}`)
}
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
throw new Error('Hourly build timestamp is invalid.')
}
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-US', {
timeZone: RELEASE_NAME_TIME_ZONE,
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
// Why h23 rather than hour12: false: some ICU builds render midnight as 24.
hourCycle: 'h23'
})
.formatToParts(date)
.map((part) => [part.type, part.value])
)
return [
version.split('-')[0],
String(buildNumber).padStart(2, '0'),
`${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`,
formatReleaseTitleTimestamp(date),
commit.slice(0, 7)
].join(' • ')
}
@@ -5038,5 +5038,14 @@
},
"settings.appearance.statusBar.sshToggleDescription": {
"ko": "사용 가능한 SSH 및 원격 Orca 호스트가 있으면 표시합니다."
},
"auto.components.right.sidebar.AiVaultPanelControls.selectAllAgents": {
"ko": "모두 선택"
},
"auto.components.right.sidebar.AiVaultPanelControls.clearAgents": {
"ko": "모두 해제"
},
"auto.components.right.sidebar.AiVaultPanel.noAgentsSelected": {
"ko": "선택된 에이전트가 없습니다"
}
}
@@ -1,16 +0,0 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
function readCreatureNames(path) {
const source = readFileSync(path, 'utf8')
return Array.from(source.matchAll(/^ '([^']+)',?$/gm), (match) => match[1])
}
describe('marine creature corpus mirrors', () => {
it('keeps the mobile mirror in parity with the shared corpus', () => {
const sharedNames = readCreatureNames('src/shared/marine-creatures.ts')
const mobileNames = readCreatureNames('mobile/src/constants/marine-creatures.ts')
expect(mobileNames).toEqual(sharedNames)
})
})
@@ -0,0 +1,30 @@
const RELEASE_NAME_TIME_ZONE = 'America/Los_Angeles'
/**
* `07-31 13:54` — the timestamp segment of a dev build's release title, shown
* verbatim in both the GitHub releases list and the in-app build picker.
*
* Why Pacific while the tag's own stamp stays UTC: that stamp is a sort key, and
* a local one would repeat an hour at every DST fall-back, making two distinct
* builds compare equal. A title is only ever read, so it uses the timezone the
* people reading it are in. The two therefore disagree by the current offset.
*/
export function formatReleaseTitleTimestamp(date) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
throw new Error('Release title timestamp is invalid.')
}
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-US', {
timeZone: RELEASE_NAME_TIME_ZONE,
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
// Why h23 rather than hour12: false: some ICU builds render midnight as 24.
hourCycle: 'h23'
})
.formatToParts(date)
.map((part) => [part.type, part.value])
)
return `${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`
}
+17 -1
View File
@@ -20,11 +20,27 @@ function quoteWindowsArg(value) {
return `"${value.replace(/"/g, '""')}"`
}
// Why: under a Git Bash setup runner, Orca exports ORCA_WORKTREE_PATH in MSYS form (/c/...), which
// cmd.exe cannot resolve. This is the migration pattern for any setup script feeding a native exe.
function posixShellPathToNativeWindowsPath(value) {
const driveMatch = value.match(/^\/([A-Za-z])\/(.*)$/)
if (driveMatch) {
return `${driveMatch[1].toUpperCase()}:\\${driveMatch[2].replace(/\//g, '\\')}`
}
return value
}
function spawnOptionalSetup(spawn, setupPath, worktreePath, platform, env) {
if (platform === 'win32') {
const nativeWorktreePath = posixShellPathToNativeWindowsPath(worktreePath)
spawn(
env.ComSpec || 'cmd.exe',
['/d', '/s', '/c', `call ${quoteWindowsArg(setupPath)} ${quoteWindowsArg(worktreePath)}`],
[
'/d',
'/s',
'/c',
`call ${quoteWindowsArg(setupPath)} ${quoteWindowsArg(nativeWorktreePath)}`
],
{
stdio: 'inherit',
windowsVerbatimArguments: true
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
#
# Creates stablyai/orca-adhoc and grants the existing release App write access to
# it, so adhoc-mac-build.yml can publish there.
#
# Why a separate repo rather than reusing orca-hourly: an adhoc build is somebody's
# unlanded branch. Sharing hourly's repo would put branch builds in the list a
# developer riding main sees, and the two are different levels of unvetted.
#
# Why no secrets are set here: the adhoc workflow reuses the same GitHub App as
# hourly — 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-adhoc-release-repo.sh
#
set -euo pipefail
ORG="stablyai"
ADHOC_REPO="$ORG/orca-adhoc"
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/$ADHOC_REPO" --jq '.full_name' >/dev/null 2>&1; then
echo "$ADHOC_REPO already exists."
else
echo "Creating $ADHOC_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 a branch build in a repo nobody
# watches, where they are simply lost.
gh repo create "$ADHOC_REPO" \
--public \
--description "Adhoc macOS dev builds of Orca, cut from unlanded branches. Not a source repo." \
--disable-issues \
--disable-wiki ||
fail "Could not create $ADHOC_REPO."
fi
echo
echo "Granting $APP_SLUG access to $ADHOC_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/$ADHOC_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 $ADHOC_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 $ADHOC_REPO
(keep orca-hourly selected; both dev channels use this one App)
4. Save.
EOF
fi
echo
echo "Smoke-test the pipeline (after this merges):"
echo " gh workflow run adhoc-mac-build.yml --repo $MAIN_REPO --ref main \\"
echo " -f ref=<your-branch> -f label=<short-name>"
echo " gh run watch --repo $MAIN_REPO"
@@ -8,6 +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 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
# `ps`), and never copied anywhere on disk.
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 12m">
<title>downloads: 12m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 13m">
<title>downloads: 13m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">12m</text>
<text x="90" y="14">12m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">13m</text>
<text x="90" y="14">13m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

+1 -1
View File
@@ -14,7 +14,7 @@ import { ChevronLeft, Check, RefreshCw, User } from 'lucide-react-native'
import { loadHosts } from '../../../src/transport/host-store'
import { useHostClient } from '../../../src/transport/client-context'
import { colors, spacing } from '../../../src/theme/mobile-theme'
import { styles } from './accounts-screen-styles'
import { styles } from '../../../src/accounts/mobile-accounts-screen-styles'
import { useNow } from '../../../src/hooks/use-now'
import { ClaudeIcon, OpenAIIcon } from '../../../src/components/AgentIcons'
import {
@@ -268,8 +268,8 @@ import {
reconcileMobileSessionCreateWarningState
} from '../../../../src/session/mobile-session-create-warning-state'
import { colors, spacing } from '../../../../src/theme/mobile-theme'
import { styles } from './mobile-session-styles'
import { QuickCommandsTabButton } from './QuickCommandsTabButton'
import { QuickCommandsTabButton } from '../../../../src/session/QuickCommandsTabButton'
import { styles } from '../../../../src/session/mobile-session-styles'
import type { DiffComment, TerminalQuickCommand } from '../../../../../src/shared/types'
import type {
DiffLineRowProps,
@@ -292,11 +292,11 @@ import type {
TerminalCreateResult,
TerminalGestureInputBucket,
TerminalGestureInputQueue
} from './mobile-session-route-types'
} from '../../../../src/session/mobile-session-route-types'
import { createMobileTranslator, t } from '@/i18n/mobile-i18n'
import { addRefreshTimer, type RefreshTimerLifecycle } from '@/session/session-refresh-timer'
const tr = createMobileTranslator('worktreeId')
import { addRefreshTimer, type RefreshTimerLifecycle } from '@/session/session-refresh-timer'
const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
@@ -1,5 +1,5 @@
import { StyleSheet } from 'react-native'
import { colors, spacing, typography, radii } from '../../../src/theme/mobile-theme'
import { colors, spacing, typography, radii } from '../theme/mobile-theme'
export const styles = StyleSheet.create({
container: {
+1 -1
View File
@@ -32,7 +32,7 @@ function OmpIcon({ size = 16 }: { size?: number }) {
<Defs>
<LinearGradient id="ompMarkGradient" x1="0" y1="0" x2="1" y2="1">
<Stop offset="0" stopColor="#ed4abf" />
<Stop offset=".5" stopColor="#9b4dff" />
<Stop offset="0.5" stopColor="#9b4dff" />
<Stop offset="1" stopColor="#5ad8e6" />
</LinearGradient>
</Defs>
@@ -0,0 +1,65 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MobileAgentIcon } from './MobileAgentIcon'
vi.mock('react-native', () => ({
Image: 'Image',
StyleSheet: { create: (styles: unknown) => styles },
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({
Terminal: 'Terminal'
}))
vi.mock('react-native-svg', () => ({
default: 'Svg',
Defs: 'Defs',
G: 'G',
LinearGradient: 'LinearGradient',
Path: 'Path',
Stop: 'Stop'
}))
vi.mock('./mobile-agent-icon-assets', () => ({
MOBILE_AGENT_ICON_ASSETS: {}
}))
vi.mock('./AgentIcons', () => ({
ClaudeIcon: 'ClaudeIcon',
OpenAIIcon: 'OpenAIIcon'
}))
describe('MobileAgentIcon OMP gradient', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.restoreAllMocks()
})
it('uses valid SVG gradient stop offsets', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) {
throw new Error(String(args[0]))
}
})
await act(async () => {
renderer = create(createElement(MobileAgentIcon, { agentId: 'omp' }))
})
consoleError.mockRestore()
expect(renderer.root.findAllByType('Stop').map((stop) => stop.props.offset)).toEqual([
'0',
'0.5',
'1'
])
})
})
@@ -1,4 +1,4 @@
import { MARINE_CREATURES } from '../constants/marine-creatures'
import { MARINE_CREATURES } from '../../../src/shared/marine-creatures'
// Why: matches the desktop fallback in
// src/renderer/src/components/sidebar/worktree-name-suggestions.ts. The
-557
View File
@@ -1,557 +0,0 @@
/* eslint-disable max-lines -- Curated marine-creature name corpus; one entry per line is the readable format for a flat data list. */
export const MARINE_CREATURES = [
'Nautilus',
'Seahorse',
'Starfish',
'Coral',
'Narwhal',
'Jellyfish',
'Octopus',
'Manta',
'Dolphin',
'Manatee',
'Cuttlefish',
'Anemone',
'Urchin',
'Triton',
'Nudibranch',
'Coelacanth',
'Oarfish',
'Beluga',
'Dugong',
'Porpoise',
'Stingray',
'Hammerhead',
'Wobbegong',
'Sawfish',
'Chimaera',
'Anglerfish',
'Viperfish',
'Dragonfish',
'Axolotl',
'Otter',
'Seadragon',
'Seahare',
'Squid',
'Argonaut',
'Sponge',
'Barnacle',
'Cowrie',
'Guitarfish',
'Lamprey',
'Isopod',
'Lionfish',
'Clownfish',
'Angelfish',
'Butterflyfish',
'Parrotfish',
'Pufferfish',
'Moonfish',
'Firefish',
'Unicornfish',
'Rainbowfish',
'Betta',
'Discus',
'Arowana',
'Koi',
'Piranha',
'Barracuda',
'Moray',
'Sunfish',
'Lanternfish',
'Archerfish',
'Mudskipper',
'Hatchetfish',
'Knifefish',
'Leaffish',
'Glassfish',
'Ropefish',
'Bichir',
'Tigerfish',
'Cardinalfish',
'Lungfish',
'Opah',
'Frogfish',
'Stonefish',
'Cutlassfish',
'Paddlefish',
'Arapaima',
'Mandarin',
'Blobfish',
'Thresher',
'Vaquita',
'Pipefish',
'Guppy',
'Tetra',
'Danio',
'Cichlid',
'Oscar',
'Gourami',
'Killifish',
'Rasbora',
'Pleco',
'Goldfish',
'Molly',
'Platy',
'Barb',
'Loach',
'Medaka',
'Pacu',
'Filefish',
'Boxfish',
'Cowfish',
'Surgeonfish',
'Damselfish',
'Wrasse',
'Goby',
'Blenny',
'Conger',
'Sculpin',
'Darter',
'Remora',
'Pilotfish',
'Trumpetfish',
'Cornetfish',
'Jawfish',
'Toadfish',
'Pearlfish',
'Driftfish',
'Lumpfish',
'Snailfish',
'Stickleback',
'Halfbeak',
'Snipefish',
'Pencilfish',
'Snakehead',
'Tripletail',
'Lookdown',
'Sweetlips',
'Squirrelfish',
'Soldierfish',
'Rabbitfish',
'Hawkfish',
'Bannerfish',
'Batfish',
'Chromis',
'Anthias',
'Fusilier',
'Sweeper',
'Emperor',
'Ponyfish',
'Kelpfish',
'Weever',
'Pearlside',
'Opaleye',
'Ballyhoo',
'Needlefish',
'Triggerfish',
'Gar',
'Tarpon',
'Sailfish',
'Conch',
'Walrus',
'Seal',
'Penguin',
'Hagfish',
'Gulper',
'Hydra',
'Krill',
'Salp',
'Tunicate',
'Crinoid',
'Polyp',
'Limpet',
'Whelk',
'Bowfin',
'Minnow',
'Gudgeon',
'Goldeye',
'Mooneye',
'Soapfish',
'Leatherjacket',
'Rudderfish',
'Sabrefish',
'Puffin',
'Albatross',
'Osprey',
'Pelican',
'Petrel',
'Gannet',
'Tern',
'Cormorant',
'Leatherback',
'Hawksbill',
'Loggerhead',
'Dottyback',
'Basslet',
'Gramma',
'Hamlet',
'Hogfish',
'Dartfish',
'Foxface',
'Shrimpfish',
'Pyrosome',
'Seapen',
'Medusa',
'Candlefish',
'Silverside',
'Shiner',
'Eelpout',
'Madtom',
'Shovelnose',
'Sandlance',
'Tubesnout',
'Marlin',
'Bonefish',
'Dace',
'Rudd',
'Spirula',
'Permit',
'Grunt',
'Trevally',
'Croaker',
'Drum',
'Ladyfish',
'Bullhead',
'Torpedo',
'Skate',
'Hermit',
'Fiddler',
'Fireworm',
'Tubeworm',
'Amphipod',
'Copepod',
'Pteropod',
'Seaslug',
'Swordtail',
'Bluegill',
'Pickerel',
'Redhorse',
'Logperch',
'Goblin',
'Catshark',
'Harlequin',
'Murex',
'Volute',
'Wentletrap',
'Nerite',
'Stargazer',
'Handfish',
'Flyingfish',
'Waspfish',
'Skua',
'Shearwater',
'Guillemot',
'Razorbill',
'Auk',
'Fulmar',
'Murre',
'Velvetfish',
'Prowfish',
'Sandperch',
'Bobtail',
'Chiton',
'Featherstar',
'Nereid',
'Siren',
'Kraken',
'Selkie',
'Leviathan',
'Wolffish',
'Wreckfish',
'Horseshoe',
'Orca',
'Cachalot',
'Rorqual',
'Grampus',
'Humpback',
'Bowhead',
'Finback',
'Sealion',
'Pinniped',
'Cetacean',
'Mako',
'Porbeagle',
'Dogfish',
'Spurdog',
'Tope',
'Bonnethead',
'Sixgill',
'Sevengill',
'Angelshark',
'Houndshark',
'Megamouth',
'Cookiecutter',
'Bramble',
'Ghostshark',
'Sandtiger',
'Galeocerdo',
'Nursehound',
'Cownose',
'Devilray',
'Eagleray',
'Butterflyray',
'Numbfish',
'Stingaree',
'Mantaray',
'Tuna',
'Albacore',
'Bonito',
'Skipjack',
'Wahoo',
'Kingfish',
'Mackerel',
'Cero',
'Dorado',
'Mahimahi',
'Escolar',
'Pomfret',
'Butterfish',
'Cod',
'Haddock',
'Pollock',
'Whiting',
'Hake',
'Ling',
'Cusk',
'Burbot',
'Saithe',
'Sablefish',
'Lingcod',
'Greenling',
'Rockling',
'Halibut',
'Flounder',
'Sole',
'Plaice',
'Turbot',
'Brill',
'Dab',
'Fluke',
'Megrim',
'Sanddab',
'Herring',
'Sardine',
'Anchovy',
'Sprat',
'Pilchard',
'Menhaden',
'Shad',
'Alewife',
'Salmon',
'Trout',
'Char',
'Grayling',
'Steelhead',
'Kokanee',
'Chinook',
'Coho',
'Sockeye',
'Taimen',
'Huchen',
'Cisco',
'Inconnu',
'Vendace',
'Whitefish',
'Perch',
'Bass',
'Snapper',
'Grouper',
'Seabass',
'Tilefish',
'Bream',
'Porgy',
'Sheepshead',
'Pinfish',
'Scup',
'Tautog',
'Sander',
'Zander',
'Walleye',
'Sauger',
'Ruffe',
'Comber',
'Hind',
'Coney',
'Graysby',
'Margate',
'Pompano',
'Amberjack',
'Yellowtail',
'Scad',
'Runner',
'Mullet',
'Goatfish',
'Threadfin',
'Snook',
'Barramundi',
'Milkfish',
'Mojarra',
'Weakfish',
'Corbina',
'Queenfish',
'Kahawai',
'Tilapia',
'Cunner',
'Tuskfish',
'Razorfish',
'Tang',
'Porcupinefish',
'Burrfish',
'Eel',
'Wolfeel',
'Gardeneel',
'Ribboneel',
'Cuskeel',
'Catfish',
'Cory',
'Ratfish',
'Elephantfish',
'Goosefish',
'Monkfish',
'Sargassum',
'Coffinfish',
'Seadevil',
'Scorpionfish',
'Rockfish',
'Cabezon',
'Rosefish',
'Redfish',
'Bocaccio',
'Thornyhead',
'Gurnard',
'Searobin',
'Fangtooth',
'Bristlemouth',
'Barreleye',
'Spookfish',
'Telescopefish',
'Lancetfish',
'Tripodfish',
'Spiderfish',
'Daggertooth',
'Pearleye',
'Ridgehead',
'Snaggletooth',
'Sturgeon',
'Sterlet',
'Kaluga',
'Cockle',
'Mussel',
'Clam',
'Oyster',
'Scallop',
'Abalone',
'Periwinkle',
'Cerith',
'Turban',
'Tellin',
'Geoduck',
'Quahog',
'Piddock',
'Shipworm',
'Lobster',
'Crayfish',
'Shrimp',
'Prawn',
'Langostino',
'Langouste',
'Yabby',
'Marron',
'Dungeness',
'Seafan',
'Staghorn',
'Elkhorn',
'Hydroid',
'Zoanthid',
'Gorgonian',
'Seanettle',
'Moonjelly',
'Sanddollar',
'Brittlestar',
'Basketstar',
'Sunstar',
'Seacucumber',
'Seabiscuit',
'Hearturchin',
'Bristleworm',
'Featherduster',
'Lugworm',
'Sandworm',
'Palolo',
'Arrowworm',
'Acornworm',
'Flatworm',
'Ribbonworm',
'Booby',
'Frigatebird',
'Tropicbird',
'Noddy',
'Kittiwake',
'Dovekie',
'Shag',
'Anhinga',
'Skimmer',
'Jaeger',
'Oystercatcher',
'Eider',
'Scoter',
'Merganser',
'Brant',
'Ridley',
'Flatback',
'Terrapin',
'Seasnake',
'Diatom',
'Plankton',
'Larvacean',
'Doliolid',
'Kelp',
'Seagrass',
'Eelgrass',
'Dulse',
'Bladderwrack',
'Mola',
// Mythological sea & water creatures from public-domain folklore — joining
// Kraken, Leviathan, Siren, Triton, Hydra, Medusa, Nereid, and Selkie above.
'Scylla',
'Charybdis',
'Cetus',
'Proteus',
'Glaucus',
'Hippocamp',
'Jormungandr',
'Hafgufa',
'Kelpie',
'Merrow',
'Nuckelavee',
'Afanc',
'Rusalka',
'Vodyanoy',
'Umibozu',
'Isonade',
'Ningyo',
'Mizuchi',
'Naga',
'Makara',
'Bunyip',
'Taniwha',
'Marakihau',
'Lusca',
'Undine',
'Nixie',
'Melusine',
'Ondine',
'Tiamat',
'Dagon',
'Aspidochelone',
'Capricorn',
'Merfolk',
'Merman',
'Mermaid',
'Timingila',
'Bakekujira',
'Jiaolong',
'Encantado',
'Hraesvelg'
] as const
@@ -0,0 +1,121 @@
import { readFileSync, readdirSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { basename, dirname, extname, join, relative } from 'node:path'
import ts from 'typescript'
import { describe, expect, it } from 'vitest'
const appDirectory = fileURLToPath(new URL('../app', import.meta.url))
const routeSourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx'])
function sourceFiles(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = join(directory, entry.name)
return entry.isDirectory() ? sourceFiles(path) : [path]
})
}
function isNonScreenExpoModule(path: string): boolean {
const fileName = basename(path)
if (/\+api\.[jt]sx?$/.test(fileName)) {
return true
}
return (
dirname(relative(appDirectory, path)) === '.' &&
/^\+(?:html|middleware|native-intent)\.[jt]sx?$/.test(fileName)
)
}
function isPlatformSpecificApiRoute(path: string): boolean {
return /\+api\.(?:android|ios|native|web)\.[jt]sx?$/.test(basename(path))
}
function hasDefaultExport(path: string, source: string): boolean {
const extension = extname(path)
const sourceFile = ts.createSourceFile(
path,
source,
ts.ScriptTarget.Latest,
true,
extension === '.jsx'
? ts.ScriptKind.JSX
: extension === '.js'
? ts.ScriptKind.JS
: extension === '.tsx'
? ts.ScriptKind.TSX
: ts.ScriptKind.TS
)
return sourceFile.statements.some((statement) => {
if (ts.isExportAssignment(statement)) {
return !statement.isExportEquals
}
if (ts.isExportDeclaration(statement) && !statement.isTypeOnly && statement.exportClause) {
if (ts.isNamespaceExport(statement.exportClause)) {
return statement.exportClause.name.text === 'default'
}
return statement.exportClause.elements.some(
(element) => !element.isTypeOnly && element.name.text === 'default'
)
}
const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined
return (
!ts.isInterfaceDeclaration(statement) &&
!ts.isTypeAliasDeclaration(statement) &&
modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DeclareKeyword) !== true &&
modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) === true &&
modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword)
)
})
}
function isInvalidRouteModule(path: string, source: string): boolean {
return (
isPlatformSpecificApiRoute(path) ||
(!isNonScreenExpoModule(path) && !hasDefaultExport(path, source))
)
}
describe('Expo route module boundary', () => {
it('allows Expo modules that are not screen routes', () => {
expect(isNonScreenExpoModule(join(appDirectory, 'health+api.ts'))).toBe(true)
expect(isNonScreenExpoModule(join(appDirectory, 'health+api.ios.ts'))).toBe(false)
expect(isNonScreenExpoModule(join(appDirectory, '+html.tsx'))).toBe(true)
expect(isNonScreenExpoModule(join(appDirectory, '+middleware.ts'))).toBe(true)
expect(isNonScreenExpoModule(join(appDirectory, '+native-intent.ts'))).toBe(true)
expect(isNonScreenExpoModule(join(appDirectory, 'nested', '+middleware.ts'))).toBe(false)
})
it('recognizes syntax-level default exports', () => {
expect(hasDefaultExport('route.tsx', 'export default function Route() {}')).toBe(true)
expect(
hasDefaultExport('route.jsx', 'export default function Route() { return <View /> }')
).toBe(true)
expect(hasDefaultExport('route.ts', "export { default } from './route-screen'")).toBe(true)
expect(hasDefaultExport('route.ts', "export { Route as default } from './route-screen'")).toBe(
true
)
expect(hasDefaultExport('support.ts', '// export default')).toBe(false)
expect(hasDefaultExport('support.ts', "const marker = 'export default'")).toBe(false)
expect(hasDefaultExport('support.ts', 'export default interface Support {}')).toBe(false)
expect(
hasDefaultExport('support.ts', "export type { Support as default } from './types'")
).toBe(false)
})
it('rejects platform-specific API routes even with a default export', () => {
expect(isPlatformSpecificApiRoute(join(appDirectory, 'health+api.ts'))).toBe(false)
for (const platform of ['android', 'ios', 'native', 'web']) {
const path = join(appDirectory, `health+api.${platform}.ts`)
expect(isInvalidRouteModule(path, 'export default function Route() {}')).toBe(true)
}
})
it('keeps support modules outside the app route directory', () => {
const invalidRoutes = sourceFiles(appDirectory)
.filter((path) => routeSourceExtensions.has(extname(path)))
.filter((path) => isInvalidRouteModule(path, readFileSync(path, 'utf8')))
.map((path) => relative(appDirectory, path))
expect(invalidRoutes).toEqual([])
})
})
@@ -1,5 +1,5 @@
import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react-native'
import type { MobileSessionTab } from '../../app/h/[hostId]/session/mobile-session-route-types'
import type { MobileSessionTab } from './mobile-session-route-types'
import { ActionSheetModal, type ActionSheetAction } from '../components/ActionSheetModal'
import { getMobileSessionTabTitle } from './mobile-terminal-tab-agent'
import { t } from '@/i18n/mobile-i18n'
@@ -1,7 +1,7 @@
import type { ComponentType } from 'react'
import { Pressable } from 'react-native'
import { colors } from '../theme/mobile-theme'
import { styles } from '../../app/h/[hostId]/session/mobile-session-styles'
import { styles } from './mobile-session-styles'
type HeaderIconProps = {
size?: number
@@ -1,7 +1,7 @@
import { Pressable, View } from 'react-native'
import { SquareChevronRight } from 'lucide-react-native'
import { colors } from '../../../../src/theme/mobile-theme'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-session-styles'
import { t } from '@/i18n/mobile-i18n'
+4 -1
View File
@@ -149,7 +149,10 @@ export async function fetchHostedReviewForBranch(
{
repo: mobileRepoSelectorFromWorktreeId(worktreeId),
branch: args.branch,
linkedGitHubPR: args.linkedGitHubPR ?? null
linkedGitHubPR: args.linkedGitHubPR ?? null,
// Why: the mobile PR sidebar is only ever open on the selected worktree,
// so it belongs in the host's fast re-check tier (#11532).
active: true
},
readForBranch
)
@@ -1,7 +1,4 @@
import type {
MarkdownDocState,
MobileSessionTab
} from '../../app/h/[hostId]/session/mobile-session-route-types'
import type { MarkdownDocState, MobileSessionTab } from './mobile-session-route-types'
import type { ActionSheetAction } from '../components/ActionSheetModal'
import {
BULK_TAB_CLOSE_ACTIONS,
@@ -1,6 +1,6 @@
import { StyleSheet } from 'react-native'
import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
export const mobileSessionCommandInputStyles = StyleSheet.create({
createWarningBanner: {
@@ -1,6 +1,6 @@
import { StyleSheet } from 'react-native'
import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
export const mobileSessionFrameStyles = StyleSheet.create({
container: {
@@ -1,6 +1,6 @@
import { Platform, StyleSheet } from 'react-native'
import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
export const mobileSessionReaderStyles = StyleSheet.create({
markdownTextInput: {
@@ -1,6 +1,6 @@
import { StyleSheet } from 'react-native'
import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
export const mobileSessionReviewCommentStyles = StyleSheet.create({
diffCommentAddButton: {
@@ -1,13 +1,10 @@
import type { MobileBrowserTab } from '../../../../src/browser/MobileBrowserPane'
import type { MobileTerminalTheme } from '../../../../src/terminal/terminal-webview-contract'
import type { MobileDiffLine } from '../../../../src/session/mobile-diff-lines'
import type {
MobileHighlightedDiffLine,
MobileSyntaxSegment
} from '../../../../src/session/mobile-file-syntax'
import type { TerminalRecord } from '../../../../src/session/mobile-terminal-records'
import type { DiffComment, TuiAgent } from '../../../../../src/shared/types'
import type { AgentStatusEntry } from '../../../../../src/shared/agent-status-types'
import type { DiffComment, TuiAgent } from '../../../src/shared/types'
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
import type { MobileBrowserTab } from '../browser/MobileBrowserPane'
import type { MobileTerminalTheme } from '../terminal/terminal-webview-contract'
import type { MobileDiffLine } from './mobile-diff-lines'
import type { MobileHighlightedDiffLine, MobileSyntaxSegment } from './mobile-file-syntax'
import type { TerminalRecord } from './mobile-terminal-records'
export type Terminal = TerminalRecord
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
import type { TuiAgent } from '../../../src/shared/types'
import type { MobileSessionTab } from '../../app/h/[hostId]/session/mobile-session-route-types'
import type { MobileSessionTab } from './mobile-session-route-types'
import {
getMobileSessionTabTitle,
resolveMobileTerminalTabAgentId
@@ -3,7 +3,7 @@ import { resolveExplicitTerminalTitleAgentType } from '../../../src/shared/termi
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
import type { TuiAgent } from '../../../src/shared/types'
import { isBlankBrowserUrl } from '../browser/browser-url'
import type { MobileSessionTab } from '../../app/h/[hostId]/session/mobile-session-route-types'
import type { MobileSessionTab } from './mobile-session-route-types'
import { t } from '@/i18n/mobile-i18n'
// Why: tab identity + title cleaning uses the same shared glyph/label maps as
@@ -10,7 +10,7 @@ const liveInputStatusSource = readFileSync(
'utf8'
)
const commandInputStylesSource = readFileSync(
new URL('../../app/h/[hostId]/session/mobile-session-command-input-styles.ts', import.meta.url),
new URL('../session/mobile-session-command-input-styles.ts', import.meta.url),
'utf8'
)
const liveInputFocusSource = readFileSync(
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { colors } from './mobile-theme'
function channelLuminance(channel: number): number {
const value = channel / 255
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
}
function relativeLuminance(hex: string): number {
const red = Number.parseInt(hex.slice(1, 3), 16)
const green = Number.parseInt(hex.slice(3, 5), 16)
const blue = Number.parseInt(hex.slice(5, 7), 16)
return (
0.2126 * channelLuminance(red) +
0.7152 * channelLuminance(green) +
0.0722 * channelLuminance(blue)
)
}
function contrastRatio(foreground: string, background: string): number {
const lighter = Math.max(relativeLuminance(foreground), relativeLuminance(background))
const darker = Math.min(relativeLuminance(foreground), relativeLuminance(background))
return (lighter + 0.05) / (darker + 0.05)
}
describe('mobile text contrast', () => {
it('keeps muted text readable on every standard dark surface', () => {
for (const surface of [colors.bgBase, colors.bgPanel, colors.bgRaised]) {
expect(contrastRatio(colors.textMuted, surface)).toBeGreaterThanOrEqual(4.5)
}
})
it('keeps secondary text more prominent than muted text', () => {
expect(contrastRatio(colors.textSecondary, colors.bgPanel)).toBeGreaterThan(
contrastRatio(colors.textMuted, colors.bgPanel)
)
})
})
+2 -2
View File
@@ -9,8 +9,8 @@ export const colors = {
editorSurface: '#1e1e1e',
textPrimary: '#e0e0e0',
textSecondary: '#888888',
textMuted: '#555555',
textSecondary: '#a1a1a1',
textMuted: '#8c8c8c',
// Crisp near-white surface for the single primary action on a screen (the
// worktree FAB). Brighter than textPrimary so it reads as a solid button, not
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.163",
"version": "1.4.165-rc.0",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -16,6 +16,7 @@
"audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings",
"audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings",
"audit:react-doctor": "pnpm dlx react-doctor@0.9.1 . --yes --no-supply-chain --no-telemetry --blocking none",
"audit:dead-code": "pnpm dlx knip@5 --config config/knip.json",
"check:code-quality:changed": "node config/scripts/check-changed-code-quality.mjs",
"check:react-doctor:changed": "node config/scripts/check-react-doctor-changed.mjs",
"check:zustand-selector-fanout": "node config/scripts/zustand-selector-fanout-benchmark.mjs --check",
@@ -219,6 +220,7 @@
"react-dom": "^19.2.7",
"react-grab": "^0.1.33",
"react-markdown": "^10.1.0",
"react-refresh": "^0.18.0",
"rehype-highlight": "^7.0.2",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
+3
View File
@@ -324,6 +324,9 @@ importers:
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.17)(react@19.2.7)
react-refresh:
specifier: ^0.18.0
version: 0.18.0
rehype-highlight:
specifier: ^7.0.2
version: 7.0.2
+2 -2
View File
@@ -527,7 +527,7 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
throwNoActiveSenderTerminal()
}
// Why: lifecycle senders keep ORCA_TERMINAL_HANDLE verbatim — no liveness probe (worker_done must survive the mid-restart window) and no remint (older runtimes require from === the stale assignee_handle).
// Why: lifecycle senders preserve ORCA_TERMINAL_HANDLE across restarts for older runtimes.
const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from')
const sendParams = {
from,
@@ -1123,7 +1123,7 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
'orchestration gate-list': async ({ flags, client, cwd, json }) => {
const run = getOptionalStringFlag(flags, 'run')
// Why: same read posture as task-list — an explicitly named Run stays inspectable without a bound pane, so identity is only resolved for the implicit "my own Run" case.
// Why: named runs remain inspectable without a pane; only implicit runs resolve identity.
const from = run ? undefined : await resolveCoordinatorTerminalHandle(flags, cwd, client)
const result = await client.call<{
gates: { id: string; task_id: string; question: string; status: string }[]
-4
View File
@@ -20,10 +20,6 @@ export type EnvironmentAddResult = {
environment: PublicKnownRuntimeEnvironment
}
export type EnvironmentListResult = {
environments: PublicKnownRuntimeEnvironment[]
}
export type EnvironmentRemoveResult = {
removed: PublicKnownRuntimeEnvironment
}
+1 -1
View File
@@ -211,7 +211,7 @@ export async function getComputerCommandTarget(
}
}
// Mirror getBrowserCommandTarget / getBrowserWorktreeSelector for emulator (workspace scoped by default + explicit --device/--emulator/--worktree; active from bridge for unqualified).
// Match browser targeting: workspace by default, explicit device/emulator/worktree overrides.
export type EmulatorCliTarget = {
worktree?: string
device?: string
@@ -0,0 +1,211 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'
import type * as FsPromises from 'node:fs/promises'
import type * as Fs from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
// Why this file is separate: the swap in writeAsync is async, so a writer parked on its
// rename has already cleared the generation guard and nothing downstream can veto it.
// flushOrThrow runs synchronously from session checkpoints and renderer shutdown, so a
// stale view landing on top of it is a real loss. Gating rename is the only way to pin it.
const gate = vi.hoisted(() => ({
blockRename: false,
blockAfterRename: false,
waiters: [] as (() => void)[],
afterRenameWaiters: [] as (() => void)[],
renameCompleted: [] as (() => void)[],
renameCalls: 0,
failUnlink: false
}))
vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof Fs>('node:fs')
const unlinkSync = ((...args: Parameters<typeof actual.unlinkSync>) => {
if (gate.failUnlink) {
throw Object.assign(new Error('busy'), { code: 'EBUSY' })
}
return actual.unlinkSync(...args)
}) as typeof actual.unlinkSync
return { ...actual, unlinkSync }
})
vi.mock('node:fs/promises', async () => {
const actual = await vi.importActual<typeof FsPromises>('node:fs/promises')
const rename = (async (...args: Parameters<typeof actual.rename>) => {
gate.renameCalls += 1
if (gate.blockRename) {
await new Promise<void>((resolve) => gate.waiters.push(resolve))
}
const result = await actual.rename(...args)
gate.renameCompleted.splice(0).forEach((resolve) => resolve())
if (gate.blockAfterRename) {
await new Promise<void>((resolve) => gate.afterRenameWaiters.push(resolve))
}
return result
}) as typeof actual.rename
return { ...actual, rename }
})
describe('ActiveViewPreference sync flush vs. parked async rename', () => {
let dir: string
const viewFile = (): string => join(dir, 'active-view.json')
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'orca-active-view-'))
gate.blockRename = false
gate.blockAfterRename = false
gate.waiters = []
gate.afterRenameWaiters = []
gate.renameCompleted = []
gate.renameCalls = 0
gate.failUnlink = false
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
})
it('a write parked on its rename cannot overwrite a later flushOrThrow', async () => {
const { ActiveViewPreference } = await import('./active-view-preference')
const pref = new ActiveViewPreference(join(dir, 'orca-data.json'), 'terminal')
gate.blockRename = true
pref.set('activity')
await vi.waitFor(() => expect(gate.renameCalls).toBeGreaterThan(0))
// Why grab it now: flushOrThrow drops pendingWrite, so waitForPendingWrite would
// return before the released rename has actually landed.
const inflight = (pref as unknown as { pendingWrite: Promise<void> | null }).pendingWrite
// A session checkpoint fires while that write is still parked.
pref.set('tasks')
pref.flushOrThrow()
expect(JSON.parse(readFileSync(viewFile(), 'utf-8')).activeView).toBe('tasks')
gate.blockRename = false
gate.waiters.splice(0).forEach((resolve) => resolve())
await inflight
expect(JSON.parse(readFileSync(viewFile(), 'utf-8')).activeView).toBe('tasks')
expect(readdirSync(dir).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
})
it('an unlink veto failure queues a newest-value correction', async () => {
const { ActiveViewPreference } = await import('./active-view-preference')
const pref = new ActiveViewPreference(join(dir, 'orca-data.json'), 'terminal')
gate.blockRename = true
pref.set('activity')
await vi.waitFor(() => expect(gate.renameCalls).toBeGreaterThan(0))
pref.set('tasks')
gate.failUnlink = true
expect(() => pref.flushOrThrow()).toThrow('busy')
gate.failUnlink = false
gate.blockRename = false
gate.waiters.splice(0).forEach((resolve) => resolve())
await pref.waitForPendingWrite()
expect(JSON.parse(readFileSync(viewFile(), 'utf-8')).activeView).toBe('tasks')
expect(readdirSync(dir).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
})
it('a completed stale rename cannot regress the persisted marker', async () => {
const { ActiveViewPreference } = await import('./active-view-preference')
const pref = new ActiveViewPreference(join(dir, 'orca-data.json'), 'terminal')
const renameCompleted = new Promise<void>((resolve) => gate.renameCompleted.push(resolve))
gate.blockAfterRename = true
pref.set('activity')
await renameCompleted
pref.set('tasks')
pref.flushOrThrow()
gate.blockAfterRename = false
gate.afterRenameWaiters.splice(0).forEach((resolve) => resolve())
await pref.waitForPendingWrite()
pref.set('activity')
await vi.waitFor(() => {
expect(JSON.parse(readFileSync(viewFile(), 'utf-8')).activeView).toBe('activity')
})
await pref.waitForPendingWrite()
})
it('a pending flush drains a view changed during its rename', async () => {
const { ActiveViewPreference } = await import('./active-view-preference')
const pref = new ActiveViewPreference(join(dir, 'orca-data.json'), 'terminal')
gate.blockRename = true
pref.set('activity')
const flush = pref.flushPendingAsync()
await vi.waitFor(() => expect(gate.renameCalls).toBeGreaterThan(0))
pref.set('tasks')
gate.blockRename = false
gate.waiters.splice(0).forEach((resolve) => resolve())
await flush
expect(JSON.parse(readFileSync(viewFile(), 'utf-8')).activeView).toBe('tasks')
expect(gate.renameCalls).toBe(2)
})
it('coalesces concurrent pending flushes while draining the newest view', async () => {
const { ActiveViewPreference } = await import('./active-view-preference')
const pref = new ActiveViewPreference(join(dir, 'orca-data.json'), 'terminal')
gate.blockRename = true
pref.set('activity')
const firstFlush = pref.flushPendingAsync()
await vi.waitFor(() => expect(gate.renameCalls).toBeGreaterThan(0))
pref.set('tasks')
const secondFlush = pref.flushPendingAsync()
expect(secondFlush).toBe(firstFlush)
gate.blockRename = false
gate.waiters.splice(0).forEach((resolve) => resolve())
await Promise.all([firstFlush, secondFlush])
expect(JSON.parse(readFileSync(viewFile(), 'utf-8')).activeView).toBe('tasks')
expect(gate.renameCalls).toBe(2)
})
it('keeps an unabortable shared drain alive when an attached signal aborts', async () => {
const { ActiveViewPreference } = await import('./active-view-preference')
const pref = new ActiveViewPreference(join(dir, 'orca-data.json'), 'terminal')
const controller = new AbortController()
gate.blockRename = true
pref.set('activity')
const flush = pref.flushPendingAsync()
await vi.waitFor(() => expect(gate.renameCalls).toBeGreaterThan(0))
expect(pref.flushPendingAsync(controller.signal)).toBe(flush)
pref.set('tasks')
controller.abort()
gate.blockRename = false
gate.waiters.splice(0).forEach((resolve) => resolve())
await expect(flush).resolves.toBeUndefined()
expect(JSON.parse(readFileSync(viewFile(), 'utf-8')).activeView).toBe('tasks')
expect(gate.renameCalls).toBe(2)
})
it('stops an abortable-only drain after its signal aborts', async () => {
const { ActiveViewPreference } = await import('./active-view-preference')
const pref = new ActiveViewPreference(join(dir, 'orca-data.json'), 'terminal')
const controller = new AbortController()
gate.blockRename = true
pref.set('activity')
const flush = pref.flushPendingAsync(controller.signal)
await vi.waitFor(() => expect(gate.renameCalls).toBeGreaterThan(0))
controller.abort()
gate.blockRename = false
gate.waiters.splice(0).forEach((resolve) => resolve())
await expect(flush).rejects.toThrow('Active-view flush aborted')
expect(gate.renameCalls).toBe(1)
})
})
+136 -23
View File
@@ -1,5 +1,5 @@
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { mkdir, rm, writeFile } from 'node:fs/promises'
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import type { TopLevelView } from '../shared/types'
import { isTopLevelView } from '../shared/top-level-view'
@@ -34,7 +34,15 @@ export class ActiveViewPreference {
private persistedActiveView: TopLevelView | null
private writeTimer: ReturnType<typeof setTimeout> | null = null
private pendingWrite: Promise<void> | null = null
private pendingFlush: Promise<void> | null = null
private flushSignals = new Set<AbortSignal>()
private unabortableFlushConsumers = 0
private writeGeneration = 0
/** Temp path of the write awaiting its rename; flushOrThrow removes it to veto that rename. */
private inFlightTmpFile: string | null = null
/** Set by flushAsync so the quit flush is the final write; see scheduleSave. */
private quitFlushStarted = false
private quitFlushPromise: Promise<void> | null = null
constructor(dataFile: string, legacyActiveView: unknown) {
this.file = getActiveViewPreferenceFile(dataFile)
@@ -65,44 +73,68 @@ export class ActiveViewPreference {
}
private scheduleSave(): void {
// Why: the quit flush is the final write; a later debounce would land unawaited mid-exit.
if (this.quitFlushStarted) {
return
}
this.writeGeneration += 1
if (this.writeTimer) {
clearTimeout(this.writeTimer)
}
this.writeTimer = setTimeout(() => {
this.writeTimer = null
const generation = this.writeGeneration
const activeView = this.activeView
const previousWrite = this.pendingWrite ?? Promise.resolve()
const nextWrite = previousWrite
.then(() => this.writeAsync(activeView, generation))
.catch((error) => {
console.error('[active-view] Failed to persist preference:', error)
})
.finally(() => {
if (this.pendingWrite === nextWrite) {
this.pendingWrite = null
}
})
this.pendingWrite = nextWrite
void this.enqueueWrite(this.activeView, this.writeGeneration)
}, SAVE_DEBOUNCE_MS)
}
private enqueueWrite(activeView: TopLevelView, generation: number): Promise<void> {
const previousWrite = this.pendingWrite ?? Promise.resolve()
const nextWrite = previousWrite
.then(() => this.writeAsync(activeView, generation))
.catch((error) => {
console.error('[active-view] Failed to persist preference:', error)
})
.finally(() => {
if (this.pendingWrite === nextWrite) {
this.pendingWrite = null
}
})
this.pendingWrite = nextWrite
return nextWrite
}
private async writeAsync(activeView: TopLevelView, generation: number): Promise<void> {
const tmpFile = `${this.file}.${process.pid}.${generation}.tmp`
let renamed = false
try {
await mkdir(dirname(this.file), { recursive: true })
await writeFile(tmpFile, serializeActiveView(activeView), 'utf-8')
// Why: keep the generation guard and the swap synchronous (no await between),
// or a concurrent flushOrThrow could rename a newer view that this stale write
// then clobbers, restoring the prior view on next launch.
// Every async writer chains on pendingWrite, so a stale generation can only lose
// here. Sync flushOrThrow skips that chain, which is what the temp-path claim below
// covers — past this point the generation guard is spent.
if (generation !== this.writeGeneration) {
return
}
renameSync(tmpFile, this.file)
renamed = true
this.persistedActiveView = activeView
this.inFlightTmpFile = tmpFile
try {
await rename(tmpFile, this.file)
renamed = true
if (generation === this.writeGeneration) {
this.persistedActiveView = activeView
}
} catch (err) {
// ENOENT: flushOrThrow removed this temp file because it already wrote a fresher view.
if (
(err as NodeJS.ErrnoException).code !== 'ENOENT' ||
generation === this.writeGeneration
) {
throw err
}
} finally {
if (this.inFlightTmpFile === tmpFile) {
this.inFlightTmpFile = null
}
}
} finally {
if (!renamed) {
await rm(tmpFile).catch(() => {})
@@ -111,16 +143,32 @@ export class ActiveViewPreference {
}
flushOrThrow(): void {
if (this.quitFlushStarted) {
throw new Error('Cannot synchronously flush active view after final persistence has started')
}
if (this.writeTimer) {
clearTimeout(this.writeTimer)
this.writeTimer = null
}
const asyncWriteWasInFlight = this.pendingWrite !== null
this.writeGeneration += 1
this.pendingWrite = null
if (!asyncWriteWasInFlight && this.activeView === this.persistedActiveView) {
return
}
// Why remove it: an async writer parked on `await rename` has already cleared the
// generation guard, so deleting what it would rename is the only thing stopping a
// stale view from landing on top of the write below.
if (this.inFlightTmpFile) {
try {
unlinkSync(this.inFlightTmpFile)
this.inFlightTmpFile = null
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
void this.enqueueWrite(this.activeView, this.writeGeneration)
throw error
}
}
}
mkdirSync(dirname(this.file), { recursive: true })
const tmpFile = `${this.file}.${process.pid}.${this.writeGeneration}.tmp`
writeFileSync(tmpFile, serializeActiveView(this.activeView), 'utf-8')
@@ -128,6 +176,71 @@ export class ActiveViewPreference {
this.persistedActiveView = this.activeView
}
/** Async twin of flushOrThrow() for the quit path; never throws so it can join the teardown barrier. */
flushAsync(): Promise<void> {
if (this.quitFlushPromise) {
return this.quitFlushPromise
}
this.quitFlushStarted = true
this.quitFlushPromise = this.flushPendingAsync()
return this.quitFlushPromise
}
flushPendingAsync(signal?: AbortSignal): Promise<void> {
if (signal?.aborted) {
return Promise.reject(new Error('Active-view flush aborted'))
}
if (signal) {
this.flushSignals.add(signal)
} else {
this.unabortableFlushConsumers += 1
}
if (this.pendingFlush) {
return this.pendingFlush
}
const run = this.drainPendingWrites()
const tracked = run.finally(() => {
if (this.pendingFlush === tracked) {
this.pendingFlush = null
this.flushSignals.clear()
this.unabortableFlushConsumers = 0
}
})
this.pendingFlush = tracked
return tracked
}
private async drainPendingWrites(): Promise<void> {
for (;;) {
if (this.allFlushConsumersAborted()) {
throw new Error('Active-view flush aborted')
}
if (this.writeTimer) {
clearTimeout(this.writeTimer)
this.writeTimer = null
}
if (this.pendingWrite === null && this.activeView === this.persistedActiveView) {
return
}
const generation = ++this.writeGeneration
await this.enqueueWrite(this.activeView, generation)
if (this.allFlushConsumersAborted()) {
throw new Error('Active-view flush aborted')
}
if (generation === this.writeGeneration) {
return
}
}
}
private allFlushConsumersAborted(): boolean {
return (
this.unabortableFlushConsumers === 0 &&
this.flushSignals.size > 0 &&
[...this.flushSignals].every((signal) => signal.aborted)
)
}
async waitForPendingWrite(): Promise<void> {
if (this.pendingWrite) {
await this.pendingWrite
@@ -24,7 +24,7 @@ describe('preserveAgentAuthBeforeRestart', () => {
})
},
store: {
flush: vi.fn(() => {
flushPendingOrThrowAsync: vi.fn(async () => {
calls.push('flush')
})
}
@@ -43,7 +43,7 @@ describe('preserveAgentAuthBeforeRestart', () => {
syncActiveWslSelectionsBeforeRestart
},
store: {
flush: vi.fn()
flushPendingOrThrowAsync: vi.fn()
}
})
@@ -70,7 +70,7 @@ describe('preserveAgentAuthBeforeRestart', () => {
})
},
store: {
flush: vi.fn(() => {
flushPendingOrThrowAsync: vi.fn(async () => {
calls.push('flush')
})
}
@@ -81,7 +81,7 @@ describe('preserveAgentAuthBeforeRestart', () => {
it('continues after WSL Codex preservation fails', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const flush = vi.fn()
const flushPendingOrThrowAsync = vi.fn()
await preserveAgentAuthBeforeRestart({
codexRuntimeHome: {
@@ -91,25 +91,25 @@ describe('preserveAgentAuthBeforeRestart', () => {
})
},
store: {
flush
flushPendingOrThrowAsync
}
})
expect(flush).toHaveBeenCalledTimes(1)
expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1)
expect(JSON.stringify(warn.mock.calls)).not.toContain('token-secret')
})
it('flushes the store when auth services are missing', async () => {
const flush = vi.fn()
const flushPendingOrThrowAsync = vi.fn()
await preserveAgentAuthBeforeRestart({ store: { flush } })
await preserveAgentAuthBeforeRestart({ store: { flushPendingOrThrowAsync } })
expect(flush).toHaveBeenCalledTimes(1)
expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1)
})
it('logs secret-free warnings and does not throw when sync fails', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const flush = vi.fn()
const flushPendingOrThrowAsync = vi.fn()
await expect(
preserveAgentAuthBeforeRestart({
@@ -124,11 +124,11 @@ describe('preserveAgentAuthBeforeRestart', () => {
throw new Error('claude-token-secret')
})
},
store: { flush }
store: { flushPendingOrThrowAsync }
})
).resolves.toBeUndefined()
expect(flush).toHaveBeenCalledTimes(1)
expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1)
expect(warn).toHaveBeenCalledTimes(2)
expect(JSON.stringify(warn.mock.calls)).not.toContain('token-secret')
})
@@ -150,7 +150,7 @@ describe('preserveAgentAuthBeforeRestart', () => {
})
},
store: {
flush: vi.fn(() => {
flushPendingOrThrowAsync: vi.fn(async () => {
calls.push('flush')
})
}
@@ -169,4 +169,19 @@ describe('preserveAgentAuthBeforeRestart', () => {
expect(calls).toEqual(['claude-start', 'flush', 'claude-finish'])
})
it('bounds a store flush that never settles', async () => {
vi.useFakeTimers()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const preservation = preserveAgentAuthBeforeRestart({
store: { flushPendingOrThrowAsync: vi.fn(() => new Promise<void>(() => {})) }
})
await vi.advanceTimersByTimeAsync(2_000)
await preservation
expect(warn).toHaveBeenCalledWith(
'[agent-auth-restart] Store persistence exceeded 2000ms; continuing restart/update'
)
})
})
+12 -12
View File
@@ -9,9 +9,12 @@ type CodexRuntimeAuthSync = Pick<
'syncForCurrentSelection' | 'syncActiveWslSelectionsBeforeRestart'
>
type ClaudeRuntimeAuthSync = Pick<ClaudeRuntimeAuthService, 'syncForCurrentSelection'>
type ShutdownStore = Pick<Store, 'flush'>
type ShutdownStore = Pick<Store, 'flushPendingOrThrowAsync'>
type AuthPreservationStep = 'Codex auth preservation' | 'Claude auth preservation'
type AuthPreservationStep =
| 'Codex auth preservation'
| 'Claude auth preservation'
| 'Store persistence'
export type AgentAuthRestartPreservationOptions = {
codexRuntimeHome?: CodexRuntimeAuthSync | null
@@ -45,10 +48,13 @@ export async function preserveAgentAuthBeforeRestart({
logStepTimeout('Codex auth preservation', 0)
}
try {
store?.flush()
} catch (error) {
logStoreFlushFailure(error)
if (store) {
const storeRemainingMs = Math.max(0, AUTH_PRESERVATION_TIMEOUT_MS - (Date.now() - startedAt))
await runWithinLifecycleTimeout(
'Store persistence',
() => store.flushPendingOrThrowAsync(),
storeRemainingMs
)
}
}
@@ -109,12 +115,6 @@ function logStepTimeout(step: AuthPreservationStep, timeoutMs: number): void {
console.warn(`[agent-auth-restart] ${step} exceeded ${timeoutMs}ms; continuing restart/update`)
}
function logStoreFlushFailure(error: unknown): void {
console.warn(
`[agent-auth-restart] Store flush failed (${describeErrorKind(error)}); continuing restart/update`
)
}
function describeErrorKind(error: unknown): string {
if (error instanceof Error) {
return error.name || 'Error'
@@ -11,6 +11,7 @@ function ingestClaudeStatus(
hookEventName: 'PermissionRequest' | 'PreToolUse'
toolName: string
toolUseId: string
interactivePrompt?: string
}
): void {
server.ingestRemote(
@@ -23,7 +24,8 @@ function ingestClaudeStatus(
payload: {
state: event.state,
agentType: 'claude',
toolName: event.toolName
toolName: event.toolName,
...(event.interactivePrompt ? { interactivePrompt: event.interactivePrompt } : {})
}
},
'connection-1'
@@ -127,6 +129,72 @@ function answeredRequestFromSnapshot(
}
}
function escapeRequestFromSnapshot(
server: AgentHookServer
): Parameters<AgentHookServer['inferInterrupt']>[0] {
return {
...answeredRequestFromSnapshot(server),
intent: 'plain-escape'
}
}
describe('inferInterrupt for Claude interactive questions', () => {
it.each(['PreToolUse', 'PermissionRequest'] as const)(
'clears a %s AskUserQuestion wait after Escape',
(hookEventName) => {
const server = new AgentHookServer()
ingestClaudeStatus(server, {
state: 'waiting',
hookEventName,
toolName: 'AskUserQuestion',
toolUseId: 'tool-question',
interactivePrompt: '{"questions":[{"question":"Pick one"}]}'
})
expect(server.inferInterrupt(escapeRequestFromSnapshot(server))).toBe(true)
const [entry] = server.getStatusSnapshot()
expect(entry).toMatchObject({ paneKey: PANE_KEY, state: 'working', agentType: 'claude' })
expect(entry.toolName).toBeUndefined()
expect(entry.interactivePrompt).toBeUndefined()
expect(entry.interrupted).toBeUndefined()
}
)
it('rejects Escape when the question baseline is stale', () => {
const server = new AgentHookServer()
ingestClaudeStatus(server, {
state: 'waiting',
hookEventName: 'PreToolUse',
toolName: 'AskUserQuestion',
toolUseId: 'tool-question'
})
const request = { ...escapeRequestFromSnapshot(server), baselineUpdatedAt: 1 }
expect(server.inferInterrupt(request)).toBe(false)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ state: 'waiting', toolName: 'AskUserQuestion' })
])
})
it.each([
['plain-escape', 'Bash'],
['ctrl-c', 'AskUserQuestion']
] as const)('does not clear a Claude wait from %s on %s', (intent, toolName) => {
const server = new AgentHookServer()
ingestClaudeStatus(server, {
state: 'waiting',
hookEventName: 'PermissionRequest',
toolName,
toolUseId: 'tool-wait'
})
expect(server.inferInterrupt({ ...escapeRequestFromSnapshot(server), intent })).toBe(false)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ state: 'waiting', toolName })
])
})
})
describe('inferQuestionAnswered', () => {
it('clears an AskUserQuestion wait when the submit keystroke is reported', () => {
const server = new AgentHookServer()
+455
View File
@@ -31,6 +31,7 @@ import {
type HookListenerState
} from '../../shared/agent-hook-listener'
import { makePaneKey } from '../../shared/stable-pane-id'
import { createShedSubagentsField } from '../../shared/agent-hook-relay'
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
getCohortAtEmitMock: vi.fn(),
@@ -55,6 +56,7 @@ const GOOD_PANE = makePaneKey('tab-good', LEAF_2)
const OLD_PANE = makePaneKey('tab-old', LEAF_3)
const FRESH_PANE = makePaneKey('tab-fresh', LEAF_4)
const TAB_A_PANE = makePaneKey('tab-A', LEAF_5)
const RUNNING_SHELL = { id: 'shell-1', type: 'shell', status: 'running' }
type Body = {
paneKey: string
@@ -187,6 +189,91 @@ describe('AgentHookServer listener replay', () => {
expect(server.getStatusSnapshot()[0]?.subagents).toEqual([child('new-child')])
})
it('restores a subagent roster the relay shed to fit the frame', () => {
const server = new AgentHookServer()
const roster = [
{ id: 'reviewer-1', agentType: 'reviewer', state: 'working' as const, startedAt: 1 }
]
server.ingestRemote(
{
paneKey: PANE,
payload: { state: 'working', prompt: 'review', agentType: 'claude', subagents: roster }
},
'conn-1'
)
// The relay dropped the roster to fit an oversized frame and said so on the wire.
server.ingestRemote(
{
paneKey: PANE,
shedFields: ['lastAssistantMessage', createShedSubagentsField(roster)],
payload: { state: 'done', prompt: 'review', agentType: 'claude' }
},
'conn-1'
)
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', subagents: roster })
})
it('lets an unmarked absent roster clear, so a finished team still retires', () => {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
payload: {
state: 'working',
prompt: 'review',
agentType: 'claude',
subagents: [{ id: 'reviewer-1', agentType: 'reviewer', state: 'working', startedAt: 1 }]
}
},
'conn-1'
)
server.ingestRemote(
{ paneKey: PANE, payload: { state: 'done', prompt: 'review', agentType: 'claude' } },
'conn-1'
)
expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined()
})
it('does not carry Claude background work across connection cleanup', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
claudeRunningNonAgentTask: true,
payload: { state: 'working', prompt: 'old host', agentType: 'claude' }
},
'conn-1'
)
server.clearStatusEntriesForConnection('conn-1')
server.ingestRemote(
{
paneKey: PANE,
payload: { state: 'working', prompt: 'new host', agentType: 'claude' }
},
'conn-2'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'new host',
baselineAgentType: 'claude',
intent: 'plain-escape'
})
).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('retains root Codex identity when relay child events omit it', () => {
const server = new AgentHookServer()
const providerSession = { key: 'session_id' as const, id: 'root-session' }
@@ -351,6 +438,232 @@ describe('AgentHookServer listener replay', () => {
}
})
it('does not infer an interrupt while Claude reports a background shell', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
claudeRunningNonAgentTask: true,
payload: { state: 'working', prompt: 'run in background', agentType: 'claude' }
},
'conn-1'
)
let baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'run in background',
baselineAgentType: 'claude',
intent: 'plain-escape'
})
).toBe(false)
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' })
vi.setSystemTime(2_000)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
claudeRunningNonAgentTask: false,
payload: { state: 'working', prompt: 'run in background', agentType: 'claude' }
},
'conn-1'
)
baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(2_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'run in background',
baselineAgentType: 'claude',
intent: 'plain-escape'
})
).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('blocks local HTTP interrupt inference for provider-owned work without exposing transport metadata', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postHook = (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload))
})
await expect(
postHook({ hook_event_name: 'UserPromptSubmit', prompt: 'run in background' })
).resolves.toMatchObject({ status: 204 })
await expect(
postHook({ hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] })
).resolves.toMatchObject({ status: 204 })
const baseline = server.getStatusSnapshot()[0]
expect(baseline).not.toHaveProperty('claudeRunningNonAgentTask')
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'run in background',
baselineAgentType: 'claude',
intent: 'plain-escape'
})
).toBe(false)
await expect(
postHook({
hook_event_name: 'Stop',
background_tasks: [],
session_crons: [{ id: 'cron-1' }]
})
).resolves.toMatchObject({ status: 204 })
const cronBaseline = server.getStatusSnapshot()[0]
expect(server._getStateForTests().claudeActiveSessionCronPaneKeys.has(PANE)).toBe(true)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: cronBaseline.receivedAt,
baselineStateStartedAt: cronBaseline.stateStartedAt,
baselinePrompt: 'run in background',
baselineAgentType: 'claude',
intent: 'ctrl-c'
})
).toBe(false)
} finally {
server.stop()
}
})
it('uses replayed Claude background metadata only before a live observation', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
isReplay: true,
claudeRunningNonAgentTask: true,
payload: { state: 'working', prompt: 'stale replay', agentType: 'claude' }
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'stale replay',
baselineAgentType: 'claude',
intent: 'ctrl-c'
})
).toBe(false)
vi.setSystemTime(1_500)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
claudeRunningNonAgentTask: false,
payload: { state: 'working', prompt: 'stale replay', agentType: 'claude' }
},
'conn-1'
)
vi.setSystemTime(2_000)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
isReplay: true,
claudeRunningNonAgentTask: true,
payload: { state: 'working', prompt: 'stale replay', agentType: 'claude' }
},
'conn-1'
)
const liveBaseline = server.getStatusSnapshot()[0]
vi.setSystemTime(2_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: liveBaseline.receivedAt,
baselineStateStartedAt: liveBaseline.stateStartedAt,
baselinePrompt: 'stale replay',
baselineAgentType: 'claude',
intent: 'ctrl-c'
})
).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('does not apply Claude background metadata from a rejected remote status', () => {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
hookEventName: 'PermissionRequest',
claudeRunningNonAgentTask: true,
payload: {
state: 'waiting',
prompt: 'approve shell',
agentType: 'claude',
toolName: 'Bash'
}
},
'conn-1'
)
const waiting = server.getStatusSnapshot()[0]
server.ingestRemote(
{
paneKey: PANE,
hookEventName: 'PreToolUse',
claudeRunningNonAgentTask: false,
payload: {
state: 'working',
prompt: 'approve shell',
agentType: 'claude',
toolName: 'OtherTool'
}
},
'conn-1'
)
expect(server.getStatusSnapshot()[0]).toEqual(waiting)
expect(server._getStateForTests().claudeRunningNonAgentTaskPaneKeys.has(PANE)).toBe(true)
})
it('carries idle subagent rows through an inferred interrupt', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
@@ -1906,6 +2219,97 @@ describe('AgentHookServer listener replay', () => {
}
})
it('accepts a new local prompt after launch authority retires in a reusable pane', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postHook = (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload, { launchToken: 'retired-launch-token' }))
})
await postHook({ hook_event_name: 'UserPromptSubmit', prompt: 'first launch' })
server.retirePaneAuthority(PANE)
await postHook({ hook_event_name: 'Stop', last_assistant_message: 'late prior hook' })
expect(server.getStatusSnapshot()).toEqual([])
await postHook({ hook_event_name: 'UserPromptSubmit', prompt: 'manual restart' })
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ paneKey: PANE, state: 'working', prompt: 'manual restart' })
])
expect(
server.attestCompatibilityAuthority({
paneKey: PANE,
launchTokenHash: createHash('sha256').update('retired-launch-token').digest('hex'),
connectionId: null,
terminalProvenance: 'current_runtime'
})
).toBeNull()
} finally {
server.stop()
}
})
it('accepts a live remote prompt but not replay after launch authority retires', () => {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
hookEventName: 'UserPromptSubmit',
launchToken: 'retired-remote-launch-token',
payload: { state: 'working', prompt: 'first launch', agentType: 'claude' }
},
'conn-1'
)
server.retirePaneAuthority(PANE)
server.ingestRemote(
{
paneKey: PANE,
hookEventName: 'UserPromptSubmit',
launchToken: 'retired-remote-launch-token',
isReplay: true,
payload: { state: 'working', prompt: 'stale replay', agentType: 'claude' }
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([])
server.ingestRemote(
{
paneKey: PANE,
hookEventName: 'UserPromptSubmit',
launchToken: 'retired-remote-launch-token',
payload: { state: 'working', prompt: 'remote manual restart', agentType: 'claude' }
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'working',
prompt: 'remote manual restart',
connectionId: 'conn-1'
})
])
expect(
server.attestCompatibilityAuthority({
paneKey: PANE,
launchTokenHash: createHash('sha256').update('retired-remote-launch-token').digest('hex'),
connectionId: 'conn-1',
terminalProvenance: 'current_runtime'
})
).toBeNull()
})
it('hydrates cached statuses as not observed in the current runtime', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-agent-hooks-'))
const firstServer = new AgentHookServer()
@@ -2693,6 +3097,48 @@ describe('AgentHookServer listener replay', () => {
}
})
it('does not apply Claude background evidence from a rejected local status', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postClaudeHook = async (payload: Record<string, unknown>): Promise<void> => {
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload))
})
expect(response.status).toBe(204)
}
await postClaudeHook({
hook_event_name: 'PermissionRequest',
prompt: 'approve shell',
tool_name: 'Bash',
background_tasks: [{ id: 'shell-1', type: 'shell', status: 'running' }],
session_crons: [{ id: 'cron-1', status: 'running' }]
})
const waiting = server.getStatusSnapshot()[0]
await postClaudeHook({
hook_event_name: 'PreToolUse',
prompt: 'approve shell',
tool_name: 'OtherTool',
background_tasks: [],
session_crons: []
})
expect(server.getStatusSnapshot()[0]).toEqual(waiting)
expect(server._getStateForTests().claudeRunningNonAgentTaskPaneKeys.has(PANE)).toBe(true)
expect(server._getStateForTests().claudeActiveSessionCronPaneKeys.has(PANE)).toBe(true)
} finally {
server.stop()
}
})
it('maps registered legacy numeric HTTP pane keys to stable pane keys', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
@@ -6261,6 +6707,13 @@ describe('Last-status persistence', () => {
{ launchToken: 'launch-bearer-must-not-persist' }
)
)
await postHookEvent(
server,
buildBody(
{ hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] },
{ launchToken: 'launch-bearer-must-not-persist' }
)
)
// Synchronous flush via stop() captures the trailing-debounced write.
server.flushStatusPersistSync()
expect(existsSync(lastStatusPath())).toBe(true)
@@ -6278,6 +6731,8 @@ describe('Last-status persistence', () => {
expect(file.entries[PANE].launchTokenHash).toBe(
createHash('sha256').update('launch-bearer-must-not-persist').digest('hex')
)
expect(file.entries[PANE].claudeRunningNonAgentTask).toBeUndefined()
expect(readFileSync(lastStatusPath(), 'utf8')).not.toContain('claudeRunningNonAgentTask')
expect(readFileSync(lastStatusPath(), 'utf8')).not.toContain('launch-bearer-must-not-persist')
} finally {
server.stop()
+156 -31
View File
@@ -41,7 +41,7 @@ import {
claudeRosterHasWorkingSubagent,
claudeRosterToSnapshots
} from '../../shared/claude-subagent-roster'
import type { AgentHookSource } from '../../shared/agent-hook-relay'
import { restoreShedStatusFields, type AgentHookSource } from '../../shared/agent-hook-relay'
import {
CLAUDE_STATUSLINE_PATHNAME,
parseClaudeStatusLineBody,
@@ -85,9 +85,14 @@ type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
stateStartedAt: number
}
type NormalizedLocalHook = {
event: AgentHookEventPayload | null
onAccepted?: () => void
}
type PersistedAgentHookEventPayload = Omit<
EnrichedAgentHookEventPayload,
'launchToken' | 'promptInteractionKey'
'claudeRunningNonAgentTask' | 'launchToken' | 'promptInteractionKey'
> & {
launchTokenHash?: string
}
@@ -737,6 +742,14 @@ export class AgentHookServer {
) {
return false
}
const dismissesClaudeQuestion =
agentType === 'claude' &&
request.intent === 'plain-escape' &&
payload.state === 'waiting' &&
isAskUserQuestionTool(payload.toolName)
if (dismissesClaudeQuestion) {
return this.inferQuestionAnswered(request)
}
// Why: inference is a fallback for a missing final hook; a strict baseline match keeps a delayed timer from clobbering any newer hook.
if (
payload.state !== 'working' ||
@@ -752,6 +765,14 @@ export class AgentHookServer {
if (payload.subagents?.some((subagent) => subagent.state !== 'idle')) {
return false
}
// Why: Escape/Ctrl+C at Claude's idle prompt does not stop provider-owned shells or session crons.
if (
agentType === 'claude' &&
(this.state.claudeRunningNonAgentTaskPaneKeys.has(existing.paneKey) ||
this.state.claudeActiveSessionCronPaneKeys.has(existing.paneKey))
) {
return false
}
// Why: keep the Claude lead-turn record in sync, or a later child event re-emits the stale 'working' state and resurrects the cancelled pane.
if (agentType === 'claude') {
@@ -784,8 +805,7 @@ export class AgentHookServer {
return true
}
/** Guarded fallback for a hook Claude never sends: answering AskUserQuestion produces no event, so re-validate the
* renderer's baseline against the cached status (a racing real hook wins) and synthesize the post-answer state. */
/** Guarded fallback for the hook Claude omits after answering or dismissing AskUserQuestion. */
inferQuestionAnswered(request: AgentQuestionAnsweredInferenceRequest): boolean {
if (!isValidPaneKey(request.paneKey)) {
return false
@@ -830,7 +850,7 @@ export class AgentHookServer {
...(payload.subagents ? { subagents: payload.subagents } : {})
}
})
console.debug('[agent-hooks] inferred answered question status', {
console.debug('[agent-hooks] inferred resolved question status', {
paneKey: inferred.paneKey,
state: inferred.payload.state
})
@@ -904,19 +924,28 @@ export class AgentHookServer {
}
}
private shouldSuppressClosedTabStatus(paneKey: string): boolean {
private getAgentStatusDisposition(
paneKey: string,
event?: { hookEventName?: string; isReplay?: boolean }
): 'accept' | 'restart' | 'suppress' {
const ownerPaneKey = this.resolvePaneKeyAlias(paneKey)
if (
const paneRetired =
this.closedAgentStatusPaneKeys.has(paneKey) ||
this.closedAgentStatusPaneKeys.has(ownerPaneKey)
) {
return true
}
const tabId = parsePaneKey(ownerPaneKey)?.tabId
if (!tabId) {
return false
if (tabId && this.closedAgentStatusTabIds.has(tabId)) {
return 'suppress'
}
return this.closedAgentStatusTabIds.has(tabId)
if (!paneRetired) {
return 'accept'
}
// Why: command completion retires launch authority but leaves its shell pane reusable.
if (event?.hookEventName === 'UserPromptSubmit' && event.isReplay !== true) {
this.closedAgentStatusPaneKeys.delete(paneKey)
this.closedAgentStatusPaneKeys.delete(ownerPaneKey)
return 'restart'
}
return 'suppress'
}
private markPaneClosedForAgentStatus(paneKey: string): void {
@@ -1030,7 +1059,10 @@ export class AgentHookServer {
}
}
private applyNormalizedStatus(payload: AgentHookEventPayload): EnrichedAgentHookEventPayload {
private applyNormalizedStatus(
payload: AgentHookEventPayload,
onAccepted?: () => void
): EnrichedAgentHookEventPayload {
const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as
| EnrichedAgentHookEventPayload
| undefined
@@ -1044,6 +1076,7 @@ export class AgentHookServer {
}
if (payload.providerSessionOnly) {
// Why: Pi session_start replaces stale turn state and survives replay, but must not emit prompt telemetry or a fabricated status.
onAccepted?.()
const enriched = this.attachStatusTiming(payload, now)
this.clearAssistantMessageRetry(enriched.paneKey)
this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey)
@@ -1154,6 +1187,7 @@ export class AgentHookServer {
) {
this.clearAssistantMessageRetry(effectivePayload.paneKey)
}
onAccepted?.()
if (!identity.inheritedFromActivePane) {
this.maybeTrackAgentPromptSent(effectivePayload, previous)
}
@@ -1295,13 +1329,13 @@ export class AgentHookServer {
) {
return
}
const normalized = normalizeHookPayload(this.state, source, body, this.env)
if (!normalized?.payload.lastAssistantMessage) {
const normalized = this.normalizeLocalHookPayload(source, body)
if (!normalized.event?.payload.lastAssistantMessage) {
this.scheduleAssistantMessageRetry(source, body, original, nextAttempt, requireExactOriginal)
return
}
// Why: some agents POST Stop before their transcript line is flushed; discovery is event-driven, later content retries stay timed.
this.applyNormalizedStatus(normalized)
this.applyNormalizedStatus(normalized.event, normalized.onAccepted)
}
setPaneKeyAliasPersistenceListener(listener: PaneKeyAliasPersistenceListener | null): void {
@@ -1622,6 +1656,48 @@ export class AgentHookServer {
return { ...record, paneKey: stablePaneKey, tabId: parsePaneKey(stablePaneKey)?.tabId }
}
private normalizeLocalHookPayload(source: AgentHookSource, body: unknown): NormalizedLocalHook {
if (source !== 'claude' || typeof body !== 'object' || body === null) {
return { event: normalizeHookPayload(this.state, source, body, this.env) }
}
const rawPaneKey = (body as Record<string, unknown>).paneKey
const paneKey = typeof rawPaneKey === 'string' ? rawPaneKey.trim() : ''
if (!paneKey) {
return { event: normalizeHookPayload(this.state, source, body, this.env) }
}
const previousRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey)
const previousActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey)
const event = normalizeHookPayload(this.state, source, body, this.env)
const nextRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey)
const nextActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey)
this.setClaudeBackgroundEvidence(paneKey, previousRunningTask, previousActiveCron)
if (!event || event.paneKey !== paneKey) {
return { event }
}
// Why: nested CLIs may inherit the pane key; only accepted statuses may mutate its background-work gate.
return {
event,
onAccepted: () => this.setClaudeBackgroundEvidence(paneKey, nextRunningTask, nextActiveCron)
}
}
private setClaudeBackgroundEvidence(
paneKey: string,
hasRunningTask: boolean,
hasActiveCron: boolean
): void {
if (hasRunningTask) {
this.state.claudeRunningNonAgentTaskPaneKeys.add(paneKey)
} else {
this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey)
}
if (hasActiveCron) {
this.state.claudeActiveSessionCronPaneKeys.add(paneKey)
} else {
this.state.claudeActiveSessionCronPaneKeys.delete(paneKey)
}
}
ingestTerminalStatus(event: {
paneKey: string
tabId?: string
@@ -1649,7 +1725,7 @@ export class AgentHookServer {
return
}
const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId
if (this.shouldSuppressClosedTabStatus(paneKey)) {
if (this.getAgentStatusDisposition(paneKey) !== 'accept') {
return
}
const worktreeId =
@@ -1718,6 +1794,9 @@ export class AgentHookServer {
providerSession?: unknown
providerSessionOnly?: unknown
isReplay?: boolean
/** Payload fields the relay dropped to fit an oversized frame; validated below. */
shedFields?: unknown
claudeRunningNonAgentTask?: unknown
payload: unknown
},
connectionId: string
@@ -1766,17 +1845,21 @@ export class AgentHookServer {
return
}
const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId
if (this.shouldSuppressClosedTabStatus(paneKey)) {
const hookEventName =
typeof envelope.hookEventName === 'string' && envelope.hookEventName.trim().length > 0
? envelope.hookEventName.trim()
: undefined
const statusDisposition = this.getAgentStatusDisposition(paneKey, {
hookEventName,
isReplay: envelope.isReplay === true
})
if (statusDisposition === 'suppress') {
return
}
const worktreeId =
envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0
? envelope.worktreeId.trim()
: undefined
const hookEventName =
typeof envelope.hookEventName === 'string' && envelope.hookEventName.trim().length > 0
? envelope.hookEventName.trim()
: undefined
const promptInteractionKey =
typeof envelope.promptInteractionKey === 'string' &&
envelope.promptInteractionKey.trim().length > 0
@@ -1796,16 +1879,27 @@ export class AgentHookServer {
: undefined
const providerSession = normalizeAgentProviderSession(envelope.providerSession) ?? undefined
// Why: relay crosses a trust boundary — re-run the canonical normalizer to enforce caps/invariants (returns null on malformed).
const normalizedPayload = normalizeAgentStatusPayload(envelope.payload)
if (!normalizedPayload) {
const validatedPayload = normalizeAgentStatusPayload(envelope.payload)
if (!validatedPayload) {
return
}
// Why: restore a shed roster only when its digest and turn identity still match the cache.
const normalizedPayload = restoreShedStatusFields(
validatedPayload,
envelope.shedFields,
this.state.lastStatusByPaneKey.get(paneKey)?.payload
)
if (
envelope.providerSessionOnly === true &&
!isValidPiProviderSessionOnly(providerSession, normalizedPayload.agentType)
) {
return
}
const applyClaudeBackgroundWork =
normalizedPayload.agentType === 'claude' &&
typeof envelope.claudeRunningNonAgentTask === 'boolean' &&
// Why: reconnect replay may seed a restarted listener, but cannot override any observation made by this runtime.
(envelope.isReplay !== true || !this.runtimeObservedStatusPaneKeys.has(paneKey))
// Why: run the HTTP path's warn-once version/env-mismatch diagnostics with this.env as expected.
warnOnHookEnvOrVersionMismatch(this.state, {
version: envelope.version,
@@ -1814,7 +1908,7 @@ export class AgentHookServer {
})
const event: AgentHookEventPayload = {
paneKey,
launchToken: envelope.launchToken,
launchToken: statusDisposition === 'restart' ? undefined : envelope.launchToken,
tabId,
worktreeId,
connectionId: trimmedConnectionId,
@@ -1827,10 +1921,25 @@ export class AgentHookServer {
providerSession,
providerSessionOnly: envelope.providerSessionOnly === true ? true : undefined,
isReplay: envelope.isReplay === true ? true : undefined,
claudeRunningNonAgentTask:
typeof envelope.claudeRunningNonAgentTask === 'boolean'
? envelope.claudeRunningNonAgentTask
: undefined,
payload: normalizedPayload
}
this.recordCurrentAuthorityObservation(event)
this.applyNormalizedStatus(event)
this.applyNormalizedStatus(
event,
applyClaudeBackgroundWork
? () => {
if (envelope.claudeRunningNonAgentTask) {
this.state.claudeRunningNonAgentTaskPaneKeys.add(paneKey)
} else {
this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey)
}
}
: undefined
)
}
async start(options?: {
@@ -1900,10 +2009,20 @@ export class AgentHookServer {
trackEmptyPaneKeyHook(body)
const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body)
const normalized = normalizeHookPayload(this.state, source, aliasedBody, this.env)
if (normalized && !this.shouldSuppressClosedTabStatus(normalized.paneKey)) {
this.recordCurrentAuthorityObservation(normalized)
const enriched = this.applyNormalizedStatus(normalized)
const normalized = this.normalizeLocalHookPayload(source, aliasedBody)
const statusDisposition = normalized.event
? this.getAgentStatusDisposition(normalized.event.paneKey, {
hookEventName: normalized.event.hookEventName,
isReplay: normalized.event.isReplay
})
: 'suppress'
if (normalized.event && statusDisposition !== 'suppress') {
const event =
statusDisposition === 'restart'
? { ...normalized.event, launchToken: undefined }
: normalized.event
this.recordCurrentAuthorityObservation(event)
const enriched = this.applyNormalizedStatus(event, normalized.onAccepted)
this.scheduleAssistantMessageRetry(source, aliasedBody, enriched)
this.scheduleCodexSubagentPoll(source, aliasedBody, enriched)
}
@@ -2017,6 +2136,11 @@ export class AgentHookServer {
// Why: a replacement remote process may reuse the pane; don't merge it with the lost connection's children.
this.state.codexSubagentRosterByPaneKey.delete(paneKey)
this.state.codexLeadStateByPaneKey.delete(paneKey)
} else if (deleted.payload.agentType === 'claude') {
this.state.claudeSubagentRosterByPaneKey.delete(paneKey)
this.state.claudeLeadStateByPaneKey.delete(paneKey)
this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey)
this.state.claudeActiveSessionCronPaneKeys.delete(paneKey)
}
}
}
@@ -2495,6 +2619,7 @@ export class AgentHookServer {
continue
}
const {
claudeRunningNonAgentTask: _claudeRunningNonAgentTask,
promptInteractionKey: _promptInteractionKey,
launchToken,
...persistedPayload
@@ -76,6 +76,7 @@ const aiVaultListResultSchema = z.object({
totalTokens: z.number(),
previewMessages: z.array(aiVaultSessionPreviewMessageSchema),
// Optional keeps paired hosts on older builds compatible.
firstUserPrompt: z.string().nullable().optional(),
lastUserPrompt: z.string().nullable().optional(),
// Default keeps remote hosts running an older build (no recoverable-signal
// fields) parseable; they simply report no recoverable-empty sessions.
@@ -0,0 +1,137 @@
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { readAiVaultFirstUserPrompt } from './session-first-user-prompt-read'
const tempRoots: string[] = []
afterEach(async () => {
// Best-effort cleanup; tests are sandboxed under mkdtemp.
const { rm } = await import('node:fs/promises')
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('readAiVaultFirstUserPrompt', () => {
it('returns the full first user prompt without preview truncation', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-first-prompt-'))
tempRoots.push(root)
const projectDir = join(root, 'project')
await mkdir(projectDir, { recursive: true })
const longPrompt = `Please implement the full vault first-prompt copy path.\n\n${'detail '.repeat(80).trimEnd()}`
const filePath = join(projectDir, 'session.jsonl')
await writeFile(
filePath,
[
JSON.stringify({
type: 'user',
sessionId: 'full-prompt-session',
timestamp: '2026-05-01T10:00:00.000Z',
cwd: '/repo/app',
isMeta: false,
message: { role: 'user', content: longPrompt }
}),
JSON.stringify({
type: 'assistant',
sessionId: 'full-prompt-session',
timestamp: '2026-05-01T10:01:00.000Z',
message: { role: 'assistant', content: 'Working on it.', model: 'claude-sonnet-4-5' }
})
].join('\n')
)
const result = await readAiVaultFirstUserPrompt({
agent: 'claude',
filePath
})
expect(result.prompt).toBe(longPrompt)
expect(result.prompt?.includes('\n\n')).toBe(true)
expect(result.prompt?.length).toBeGreaterThan(220)
})
it('extracts full Codex input_text content blocks (not preview-capped)', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-first-prompt-codex-'))
tempRoots.push(root)
const sessionPath = join(root, 'sessions', '2026', '07', '21', 'rollout-full.jsonl')
await mkdir(join(root, 'sessions', '2026', '07', '21'), { recursive: true })
const longPrompt = `Review the PR and fix real regressions.\n\n${'context '.repeat(60).trimEnd()}`
await writeFile(
sessionPath,
[
JSON.stringify({
timestamp: '2026-07-21T10:00:00.000Z',
type: 'session_meta',
payload: { id: 'codex-full-prompt', cwd: '/repo/app' }
}),
JSON.stringify({
timestamp: '2026-07-21T10:00:01.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: longPrompt }]
}
})
].join('\n')
)
const result = await readAiVaultFirstUserPrompt({
agent: 'codex',
filePath: sessionPath,
codexHome: root
})
expect(result.prompt).toBe(longPrompt)
expect(result.prompt?.length).toBeGreaterThan(220)
})
it('skips meta/harness user turns and returns the first real ask', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-first-prompt-meta-'))
tempRoots.push(root)
const projectDir = join(root, 'project')
await mkdir(projectDir, { recursive: true })
const filePath = join(projectDir, 'session.jsonl')
await writeFile(
filePath,
[
JSON.stringify({
type: 'user',
sessionId: 'meta-then-real',
timestamp: '2026-05-01T10:00:00.000Z',
cwd: '/repo/app',
isMeta: true,
message: { role: 'user', content: 'Base directory for this skill: /tmp/skills' }
}),
JSON.stringify({
type: 'user',
sessionId: 'meta-then-real',
timestamp: '2026-05-01T10:00:01.000Z',
cwd: '/repo/app',
message: { role: 'user', content: 'Ship the first-prompt copy button' }
})
].join('\n')
)
const result = await readAiVaultFirstUserPrompt({
agent: 'claude',
filePath
})
expect(result.prompt).toBe('Ship the first-prompt copy button')
})
it('resolves null instead of rejecting when the transcript is corrupt', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-first-prompt-corrupt-'))
tempRoots.push(root)
const sessionDir = join(root, 'session-1')
await mkdir(sessionDir, { recursive: true })
// Grok's parser JSON.parses summary.json eagerly, so truncated JSON throws.
const summaryPath = join(sessionDir, 'summary.json')
await writeFile(summaryPath, '{"info": {"id": "session-1", "cwd": "/repo/a')
await expect(
readAiVaultFirstUserPrompt({ agent: 'grok', filePath: summaryPath })
).resolves.toEqual({ prompt: null })
})
})
@@ -0,0 +1,144 @@
import { stat } from 'node:fs/promises'
import type {
AiVaultAgent,
AiVaultFirstUserPromptArgs,
AiVaultFirstUserPromptResult,
AiVaultSession
} from '../../shared/ai-vault-types'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
import { parseAgentSessionFile } from './session-scanner-agent-parser'
import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture'
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
import type { FileWithMtime } from './session-scanner-types'
export type ReadAiVaultFirstUserPromptArgs = {
agent: AiVaultAgent
filePath: string
sessionId?: string
executionHostId?: ExecutionHostId
codexHome?: string | null
}
export type ReadAiVaultFirstUserPromptResult = AiVaultFirstUserPromptResult
/** IPC-safe entry: validates untyped payload then reads the full first prompt. */
export async function handleAiVaultGetFirstUserPrompt(
args?: AiVaultFirstUserPromptArgs
): Promise<AiVaultFirstUserPromptResult> {
if (!args || typeof args.filePath !== 'string' || typeof args.agent !== 'string') {
return { prompt: null }
}
return readAiVaultFirstUserPrompt({
agent: args.agent,
filePath: args.filePath,
sessionId: typeof args.sessionId === 'string' ? args.sessionId : undefined,
executionHostId: args.executionHostId,
codexHome: args.codexHome
})
}
/**
* Re-parse one session transcript under full first-prompt capture and return
* the untruncated first real user ask for copy/reuse.
*/
export async function readAiVaultFirstUserPrompt(
args: ReadAiVaultFirstUserPromptArgs
): Promise<ReadAiVaultFirstUserPromptResult> {
const filePath = args.filePath.trim()
if (!filePath || !args.agent) {
return { prompt: null }
}
// Why: transcript bodies live on the session host. Remote rows are skipped
// (same posture as listSubagentSessions); UI falls back to preview text.
const executionHostId = args.executionHostId ?? LOCAL_EXECUTION_HOST_ID
if (executionHostId !== LOCAL_EXECUTION_HOST_ID) {
return { prompt: null }
}
// Why: partial/corrupt transcripts make parsers throw. Resolve null like every
// other unavailable case instead of rejecting the IPC call.
let session: AiVaultSession | null
try {
session = await withFullFirstUserPromptCapture(() =>
parseSessionForFullFirstUserPrompt({
agent: args.agent,
filePath,
sessionId: args.sessionId?.trim() || undefined,
codexHome: args.codexHome ?? null
})
)
} catch {
return { prompt: null }
}
const prompt = session?.firstUserPrompt?.trim() || null
return { prompt }
}
async function parseSessionForFullFirstUserPrompt(args: {
agent: AiVaultAgent
filePath: string
sessionId?: string
codexHome: string | null
}): Promise<AiVaultSession | null> {
// Why: OpenCode SQLite sessions store filePath as the db path (not db#id).
// Re-parse in-process under full capture so ALS applies and we can read the
// earliest user row (worker list-scan path only joins newest messages).
if (args.agent === 'opencode') {
const fromSynthetic = splitOpenCodeSqliteCandidate(args.filePath)
if (fromSynthetic) {
return parseOpenCodeSqliteSession({
dbPath: fromSynthetic.dbPath,
sessionId: fromSynthetic.sessionId,
platform: process.platform
})
}
if (args.sessionId) {
return parseOpenCodeSqliteSession({
dbPath: args.filePath,
sessionId: args.sessionId,
platform: process.platform
})
}
}
const file = await fileWithMtimeForPath(args.filePath)
if (!file) {
return null
}
return parseAgentSessionFile(
{
agent: args.agent,
file,
codexHome: args.codexHome
},
process.platform
)
}
async function fileWithMtimeForPath(filePath: string): Promise<FileWithMtime | null> {
// OpenCode SQLite candidates use a synthetic `dbPath#sessionId` path that is
// not a real filesystem object; parsers that need it accept the path as-is.
if (filePath.includes('#')) {
return {
path: filePath,
mtimeMs: 0,
modifiedAt: new Date(0).toISOString()
}
}
try {
const info = await stat(filePath)
return {
path: filePath,
mtimeMs: info.mtimeMs,
modifiedAt: info.mtime.toISOString(),
sizeBytes: info.size
}
} catch {
return null
}
}
@@ -12,6 +12,11 @@ import type {
ResumableSessionParseState,
SessionAccumulator
} from './session-scanner-types'
import {
extractFullFirstUserPromptText,
normalizeFullFirstUserPromptText,
shouldCaptureFullFirstUserPrompt
} from './session-scanner-first-user-prompt'
import {
extractPreviewContentText,
extractString,
@@ -41,6 +46,7 @@ export function createAccumulator(args: {
messageCount: 0,
totalTokens: 0,
previewMessages: [],
firstUserPrompt: null,
lastUserPrompt: null,
queuedMessageCount: 0,
subagentTranscriptCount: 0,
@@ -113,6 +119,7 @@ export function finalizeSession(
messageCount: accumulator.messageCount,
totalTokens: accumulator.totalTokens,
previewMessages: accumulator.previewMessages,
...(accumulator.firstUserPrompt ? { firstUserPrompt: accumulator.firstUserPrompt } : {}),
...(accumulator.lastUserPrompt ? { lastUserPrompt: accumulator.lastUserPrompt } : {}),
queuedMessageCount: accumulator.queuedMessageCount,
subagentTranscriptCount: accumulator.subagentTranscriptCount,
@@ -149,6 +156,9 @@ export function addPreviewMessage(
role: AiVaultSessionPreviewMessage['role']
text: string | null
timestamp?: unknown
// Why: Claude meta/injected turns still preview, but must not seed the
// copyable first-prompt row.
seedFirstUserPrompt?: boolean
}
): void {
const text = normalizePreviewText(args.text ?? '')
@@ -163,18 +173,40 @@ export function addPreviewMessage(
if (accumulator.previewMessages.length > SESSION_PREVIEW_MESSAGE_LIMIT) {
accumulator.previewMessages.shift()
}
// Why: list scans never store firstUserPrompt (payload/perf). Only the
// on-demand full-capture path seeds the untruncated copy body.
if (
args.role === 'user' &&
args.seedFirstUserPrompt !== false &&
!accumulator.firstUserPrompt &&
shouldCaptureFullFirstUserPrompt() &&
args.text
) {
accumulator.firstUserPrompt = normalizeFullFirstUserPromptText(args.text)
}
}
export function addPreviewContent(
accumulator: SessionAccumulator,
role: AiVaultSessionPreviewMessage['role'],
content: unknown,
timestamp?: unknown
timestamp?: unknown,
options?: { seedFirstUserPrompt?: boolean }
): void {
if (
role === 'user' &&
options?.seedFirstUserPrompt !== false &&
!accumulator.firstUserPrompt &&
shouldCaptureFullFirstUserPrompt()
) {
accumulator.firstUserPrompt = extractFullFirstUserPromptText(content)
}
addPreviewMessage(accumulator, {
role,
text: extractPreviewContentText(content),
timestamp
timestamp,
// Content path already seeded above when capture is enabled.
seedFirstUserPrompt: false
})
}
@@ -0,0 +1,15 @@
import { AsyncLocalStorage } from 'node:async_hooks'
// Why: list scans must not carry full first-prompt bodies (up to 500 sessions
// per refresh). On-demand copy re-parses one transcript under `full` mode.
export type FirstUserPromptCaptureMode = 'none' | 'full'
const firstUserPromptCaptureStorage = new AsyncLocalStorage<FirstUserPromptCaptureMode>()
export function getFirstUserPromptCaptureMode(): FirstUserPromptCaptureMode {
return firstUserPromptCaptureStorage.getStore() ?? 'none'
}
export function withFullFirstUserPromptCapture<T>(fn: () => Promise<T>): Promise<T> {
return firstUserPromptCaptureStorage.run('full', fn)
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { normalizeFullFirstUserPromptText } from './session-scanner-first-user-prompt'
// Mirrors FULL_FIRST_USER_PROMPT_SAFETY_LIMIT in the module under test.
const SAFETY_LIMIT = 256 * 1024
describe('AI Vault full first-user-prompt normalization', () => {
it('drops an astral char straddling the safety limit instead of splitting it', () => {
const result = normalizeFullFirstUserPromptText(`${'a'.repeat(SAFETY_LIMIT - 1)}😀tail`)
expect(result).toHaveLength(SAFETY_LIMIT - 1)
expect(result?.endsWith('a')).toBe(true)
expect(hasUnpairedSurrogate(result ?? '')).toBe(false)
})
it('keeps a prompt shorter than the safety limit intact', () => {
expect(normalizeFullFirstUserPromptText('ship it 😀')).toBe('ship it 😀')
})
})
function hasUnpairedSurrogate(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
const isHigh = code >= 0xd800 && code <= 0xdbff
const isLow = code >= 0xdc00 && code <= 0xdfff
if (isHigh) {
const next = value.charCodeAt(index + 1)
if (!(next >= 0xdc00 && next <= 0xdfff)) {
return true
}
index += 1
continue
}
if (isLow) {
return true
}
}
return false
}
@@ -0,0 +1,104 @@
import { isKnownHarnessInjectedUserTurnText } from '../../shared/harness-injected-user-turns'
import { getFirstUserPromptCaptureMode } from './session-scanner-first-user-prompt-capture'
import { stripGrokUserQueryEnvelope } from './session-scanner-grok-user-text'
// Direct import: session-scanner-values re-exports this module, so going through
// it here would close an import cycle.
import { sliceAtCodeUnitLimit } from './session-scanner-text-normalization'
// Why: safety only for pathological multi-MB pastes. Copy path must not use the
// 220-char list preview cap.
const FULL_FIRST_USER_PROMPT_SAFETY_LIMIT = 256 * 1024
// Codex uses input_text; most others use text. Never treat tool/image blocks as
// the written first ask.
const TEXT_LIKE_BLOCK_TYPES = new Set(['text', 'input_text', 'output_text'])
/** True only while an on-demand first-prompt read is re-parsing one transcript. */
export function shouldCaptureFullFirstUserPrompt(): boolean {
return getFirstUserPromptCaptureMode() === 'full'
}
/**
* Extract the written first-user ask for copy/reuse. Preserves newlines and does
* not apply list-preview caps. Returns null for non-text / harness / empty.
*/
export function extractFullFirstUserPromptText(value: unknown): string | null {
if (typeof value === 'string') {
return finalizeFullFirstUserPrompt(value)
}
// Single content block object (not wrapped in an array).
if (value && typeof value === 'object' && !Array.isArray(value)) {
const blockText = firstUserPromptContentItemText(value)
return blockText != null ? finalizeFullFirstUserPrompt(blockText) : null
}
if (!Array.isArray(value)) {
return null
}
const parts: string[] = []
for (const item of value) {
const text = firstUserPromptContentItemText(item)
if (text != null) {
parts.push(text)
}
}
if (parts.length === 0) {
return null
}
return finalizeFullFirstUserPrompt(parts.join('\n'))
}
export function normalizeFullFirstUserPromptText(value: string): string | null {
return finalizeFullFirstUserPrompt(value)
}
function finalizeFullFirstUserPrompt(value: string): string | null {
// Why: Grok (and some pasted transcripts) wrap the real ask in <user_query>;
// strip that before copy so the clipboard is the typed prompt, not user_info.
const unwrapped = stripGrokUserQueryEnvelope(value.replace(/^\uFEFF/, ''))
const trimmed = unwrapped.trim()
if (!trimmed) {
return null
}
if (isSuppressedFullFirstUserPrompt(trimmed)) {
return null
}
if (isKnownHarnessInjectedUserTurnText(trimmed)) {
return null
}
// Reject pure Grok bootstrap dumps even when they arrived via a non-Grok path.
const lower = trimmed.toLowerCase()
if (lower.startsWith('<user_info>') && !lower.includes('<user_query>')) {
return null
}
return sliceAtCodeUnitLimit(trimmed, FULL_FIRST_USER_PROMPT_SAFETY_LIMIT)
}
function isSuppressedFullFirstUserPrompt(value: string): boolean {
const head = value.slice(0, 64).toLowerCase()
return head.startsWith('# agents.md instructions') || head.startsWith('<instructions>')
}
function firstUserPromptContentItemText(item: unknown): string | null {
if (typeof item === 'string') {
return item
}
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return null
}
const record = item as Record<string, unknown>
const type = typeof record.type === 'string' ? record.type : null
if (type != null && !TEXT_LIKE_BLOCK_TYPES.has(type)) {
return null
}
if (typeof record.text === 'string' && record.text.length > 0) {
return record.text
}
// Some providers put the body on `content` for text-shaped blocks.
if (typeof record.content === 'string' && record.content.length > 0) {
return record.content
}
return null
}
@@ -1,5 +1,16 @@
import { describe, expect, it, vi } from 'vitest'
import { extractGrokContentText } from './session-scanner-grok-parser'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture'
import { extractGrokContentText, parseGrokSessionFile } from './session-scanner-grok-parser'
let tempRoots: string[] = []
afterEach(async () => {
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
})
describe('AI Vault Grok session parser', () => {
it('extracts bounded user_query text without trimming the full body', () => {
@@ -8,23 +19,94 @@ describe('AI Vault Grok session parser', () => {
`<USER_INFO>context</USER_INFO><USER_QUERY>\n${'Grok prompt '.repeat(400)}</USER_QUERY>`
)
const trimCalls = trimSpy.mock.calls.length
trimSpy.mockRestore()
expect(trimCalls).toBe(0)
// stripGrokUserQueryEnvelope trims the body once; the fold must never trim
// per character (the input here is 4800+ chars).
expect(trimCalls).toBeGreaterThan(0)
expect(trimCalls).toBeLessThan(20)
expect(result?.startsWith('Grok prompt Grok prompt')).toBe(true)
expect(result?.endsWith('...')).toBe(true)
expect(result).not.toContain('USER_QUERY')
expect(result).not.toContain('USER_INFO')
})
it('folds Grok array content without joining all text parts', () => {
const joinSpy = vi.spyOn(Array.prototype, 'join')
const result = extractGrokContentText([
{ type: 'text', text: 'Grok array '.repeat(80) },
{ type: 'text', text: 'tail' }
])
const joinCalls = joinSpy.mock.calls.length
expect(joinCalls).toBe(0)
expect(result?.startsWith('Grok array Grok array')).toBe(true)
expect(result?.endsWith('...')).toBe(true)
})
it('drops an astral char straddling the preview scan cap instead of splitting it', () => {
// Hidden context is skipped by the fold, so the 4096-code-unit scan cap lands
// mid-emoji while the visible text stays well under the 220-char preview cap.
const hidden = `<system-reminder>${'x'.repeat(4057)}</system-reminder>`
const result = extractGrokContentText(`${hidden}ask😀tail`)
expect(hidden).toHaveLength(4092)
expect(result).toBe('ask')
})
it('stores the unwrapped user_query as firstUserPrompt under full capture', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-grok-first-'))
tempRoots.push(root)
const sessionDir = join(root, 'session-1')
await mkdir(sessionDir, { recursive: true })
const summaryPath = join(sessionDir, 'summary.json')
await writeFile(
summaryPath,
JSON.stringify({
info: { id: 'session-1', cwd: '/repo/app' },
generated_title: 'Grok title',
created_at: '2026-05-01T10:00:00.000Z',
updated_at: '2026-05-01T10:05:00.000Z'
})
)
const realAsk = 'fix i18n keep ko workspace worktree and primary'
await writeFile(
join(sessionDir, 'chat_history.jsonl'),
[
JSON.stringify({
type: 'user',
content: [
{
type: 'text',
text: [
'<user_info>',
'OS Version: macos',
'Shell: /opt/homebrew/bin/bash',
'Workspace Path: /Users/ada/repo',
"Today's date: 2026-08-01",
'Note: Prefer using relative paths over absolute paths as tool call args when possible.',
'</user_info>',
`<user_query>\n${realAsk}\n</user_query>`
].join('\n')
}
],
timestamp: '2026-05-01T10:00:01.000Z'
}),
JSON.stringify({
type: 'assistant',
content: 'On it.',
timestamp: '2026-05-01T10:00:02.000Z'
})
].join('\n')
)
const session = await withFullFirstUserPromptCapture(() =>
parseGrokSessionFile({
path: summaryPath,
mtimeMs: Date.now(),
modifiedAt: new Date().toISOString()
})
)
expect(session?.firstUserPrompt).toBe(realAsk)
expect(session?.firstUserPrompt).not.toContain('user_info')
expect(session?.firstUserPrompt).not.toContain('OS Version')
})
})
@@ -11,6 +11,14 @@ import {
sessionIdFromFileName,
updateTimeline
} from './session-scanner-accumulator'
import {
normalizeFullFirstUserPromptText,
shouldCaptureFullFirstUserPrompt
} from './session-scanner-first-user-prompt'
import {
extractGrokFirstUserPromptText,
stripGrokUserQueryEnvelope
} from './session-scanner-grok-user-text'
import {
asRecord,
extractPreviewContentText,
@@ -18,7 +26,8 @@ import {
normalizePreviewText,
normalizeTitleText,
numberValue,
parseJsonObject
parseJsonObject,
sliceAtCodeUnitLimit
} from './session-scanner-values'
const GROK_USER_QUERY_PREVIEW_SCAN_LIMIT = 4096
@@ -68,14 +77,38 @@ async function consumeGrokChatHistory(
if (role !== 'user' && role !== 'assistant') {
continue
}
const text = extractGrokContentText(record.content)
if (role === 'user') {
accumulator.title ??= normalizeTitleText(text ?? '')
// Why: first-prompt copy must be the typed ask inside <user_query>, never
// the injected <user_info> bootstrap row.
const firstPromptBody = extractGrokFirstUserPromptText(record.content)
const text = firstPromptBody
? normalizePreviewText(capGrokPreviewSource(firstPromptBody))
: null
if (firstPromptBody) {
accumulator.title ??= normalizeTitleText(firstPromptBody)
if (shouldCaptureFullFirstUserPrompt() && !accumulator.firstUserPrompt) {
accumulator.firstUserPrompt = normalizeFullFirstUserPromptText(firstPromptBody)
}
}
if (text) {
addPreviewMessage(accumulator, {
role: 'user',
text,
timestamp: extractString(record.timestamp),
seedFirstUserPrompt: false
})
}
continue
}
addPreviewMessage(accumulator, {
role,
text,
timestamp: extractString(record.timestamp)
role: 'assistant',
text: extractGrokContentText(record.content),
timestamp: extractString(record.timestamp),
seedFirstUserPrompt: false
})
}
} catch {
@@ -90,45 +123,11 @@ export function extractGrokContentText(value: unknown): string | null {
return extractPreviewContentText(value)
}
function capGrokPreviewSource(text: string): string {
return sliceAtCodeUnitLimit(text, GROK_USER_QUERY_PREVIEW_SCAN_LIMIT)
}
function extractGrokStringContentText(text: string): string | null {
const bounds = grokUserQueryEnvelopeBounds(text)
if (!bounds) {
return normalizePreviewText(text)
}
const boundedEnd = Math.min(bounds.end, bounds.start + GROK_USER_QUERY_PREVIEW_SCAN_LIMIT)
return normalizePreviewText(text.slice(bounds.start, boundedEnd)) ?? normalizePreviewText(text)
}
function grokUserQueryEnvelopeBounds(text: string): { start: number; end: number } | null {
const opener = '<user_query>'
const startIndex = indexOfAsciiIgnoreCase(text, opener, 0)
if (startIndex === -1) {
return null
}
const bodyStartIndex = startIndex + opener.length
const endIndex = indexOfAsciiIgnoreCase(text, '</user_query>', bodyStartIndex)
if (endIndex === -1) {
return null
}
return { start: bodyStartIndex, end: endIndex }
}
function indexOfAsciiIgnoreCase(value: string, search: string, fromIndex: number): number {
const lastStart = value.length - search.length
for (let index = Math.max(0, fromIndex); index <= lastStart; index++) {
let matches = true
for (let offset = 0; offset < search.length; offset++) {
const code = value.charCodeAt(index + offset)
const normalizedCode = code >= 65 && code <= 90 ? code + 32 : code
if (normalizedCode !== search.charCodeAt(offset)) {
matches = false
break
}
}
if (matches) {
return index
}
}
return -1
const unwrapped = stripGrokUserQueryEnvelope(text)
return normalizePreviewText(capGrokPreviewSource(unwrapped))
}
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import {
extractGrokFirstUserPromptText,
isGrokBootstrapContextText,
stripGrokUserQueryEnvelope
} from './session-scanner-grok-user-text'
describe('Grok first-user prompt text', () => {
it('unwraps user_query and drops the user_info bootstrap envelope', () => {
const raw = [
'<user_info>',
'OS Version: macos',
'Shell: /opt/homebrew/bin/bash',
'Workspace Path: /Users/ada/repo',
"Today's date: 2026-08-01",
'Note: Prefer using relative paths',
'</user_info>',
'<user_query>',
'fix i18n keep ko workspace worktree and primary',
'</user_query>'
].join('\n')
expect(extractGrokFirstUserPromptText(raw)).toBe(
'fix i18n keep ko workspace worktree and primary'
)
expect(stripGrokUserQueryEnvelope(raw)).toBe('fix i18n keep ko workspace worktree and primary')
})
it('unwraps user_query even when the closing tag is missing', () => {
const raw = '<user_info>context</user_info><user_query>\nShip the full first prompt copy path'
expect(extractGrokFirstUserPromptText(raw)).toBe('Ship the full first prompt copy path')
})
it('rejects pure user_info bootstrap rows', () => {
const bootstrap = [
'<user_info>',
'OS Version: macos',
'Note: Prefer using relative paths over absolute paths',
'</user_info>'
].join('\n')
expect(isGrokBootstrapContextText(bootstrap)).toBe(true)
expect(extractGrokFirstUserPromptText(bootstrap)).toBeNull()
})
it('keeps ordinary user prompts', () => {
expect(extractGrokFirstUserPromptText('fix the flaky vault tests')).toBe(
'fix the flaky vault tests'
)
})
})
@@ -0,0 +1,104 @@
// Shared Grok user-turn text helpers for AI Vault (list preview + full first-prompt
// copy). Kept out of the native-chat decoder so vault scanners stay free of that
// dependency while matching its bootstrap / user_query rules.
/**
* Prefer the body of a Grok `<user_query>` envelope when present (closing tag
* optional). Bootstrap `<user_info>`-only rows return null so a later real ask
* can seed the first prompt.
*/
export function extractGrokFirstUserPromptText(value: unknown): string | null {
const raw = flattenGrokUserContent(value)
if (!raw) {
return null
}
if (isGrokBootstrapContextText(raw)) {
return null
}
const unwrapped = stripGrokUserQueryEnvelope(raw)
const trimmed = unwrapped.trim()
if (!trimmed || isGrokBootstrapContextText(trimmed)) {
return null
}
// Why: if the turn is still just a user_info dump (no query envelope), it is
// not the prompt the user typed and must not be copied as "first prompt".
if (
startsWithIgnoreCaseTag(trimmed, 'user_info') &&
!containsIgnoreCaseTag(trimmed, 'user_query')
) {
return null
}
return trimmed
}
function flattenGrokUserContent(value: unknown): string | null {
if (typeof value === 'string') {
return value
}
if (!Array.isArray(value)) {
return null
}
const parts: string[] = []
for (const item of value) {
if (typeof item === 'string') {
parts.push(item)
continue
}
if (!item || typeof item !== 'object') {
continue
}
const record = item as Record<string, unknown>
if (typeof record.type === 'string' && record.type !== 'text') {
continue
}
if (typeof record.text === 'string') {
parts.push(record.text)
}
}
return parts.length > 0 ? parts.join('\n') : null
}
export function stripGrokUserQueryEnvelope(text: string): string {
const opener = '<user_query>'
const closer = '</user_query>'
const lower = text.toLowerCase()
const start = lower.indexOf(opener)
if (start === -1) {
return text
}
const bodyStart = start + opener.length
const end = lower.indexOf(closer, bodyStart)
// Why: incomplete closing tag still holds the real ask after the opener.
if (end === -1) {
return text.slice(bodyStart).trim()
}
return text.slice(bodyStart, end).trim()
}
export function isGrokBootstrapContextText(text: string): boolean {
const normalized = text.trim().toLowerCase()
if (!normalized.startsWith('<user_info>')) {
return false
}
const userInfoEnd = normalized.indexOf('</user_info>')
if (userInfoEnd === -1) {
// Open-ended user_info dump with no query: treat as bootstrap noise.
return !normalized.includes('<user_query>')
}
const remainder = normalized.slice(userInfoEnd + '</user_info>'.length).trim()
// Why: Grok appends a git snapshot to the bootstrap row; reject that envelope
// so real prompts mentioning either tag still count as user asks.
return (
remainder.length === 0 ||
(remainder.startsWith('<git_status>') && remainder.endsWith('</git_status>'))
)
}
function startsWithIgnoreCaseTag(text: string, tagName: string): boolean {
const lower = text.trimStart().toLowerCase()
return lower.startsWith(`<${tagName}>`) || lower.startsWith(`<${tagName} `)
}
function containsIgnoreCaseTag(text: string, tagName: string): boolean {
return text.toLowerCase().includes(`<${tagName}>`)
}
@@ -134,5 +134,7 @@ describe('scanAiVaultSessions harness-injected title seeding', () => {
expect(result.issues).toEqual([])
expect(result.sessions[0]?.lastUserPrompt).toBe('Fix the zoom behavior in a separate PR')
// Meta skill preamble must not become the copyable first prompt.
expect(result.sessions[0]?.firstUserPrompt).toBeUndefined()
})
})
@@ -1,4 +1,4 @@
import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import type { SessionFileCandidate } from './session-scanner-types'
// Why: request/response shapes shared by the worker entry and the main-thread
@@ -30,8 +30,6 @@ export type OpenCodeSqliteListValue = {
issues: AiVaultScanIssue[]
}
export type OpenCodeSqliteParseValue = AiVaultSession | null
export type OpenCodeSqliteWorkerResponse =
| { id: number; ok: true; value: unknown }
| { id: number; ok: false; error: string }
@@ -6,6 +6,7 @@ import Database from '../sqlite/sync-database'
import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths'
import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-discovery'
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
let tempDirs: string[] = []
@@ -496,4 +497,107 @@ describe('parseOpenCodeSqliteSession', () => {
expect(session).not.toBeNull()
expect(session!.model).toBe('claude-sonnet-4-5')
})
it('captures every text part of the earliest user message and no later turn', async () => {
const { db, path } = createTempDb()
applyOpenCodeSchema(db)
insertSession(db, {
id: 'ses_fp',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_900_000
})
insertMessage(db, {
id: 'msg_1',
sessionId: 'ses_fp',
role: 'user',
timeCreated: 1_777_634_000_000
})
insertPart(db, {
id: 'part_1a',
messageId: 'msg_1',
sessionId: 'ses_fp',
timeCreated: 10,
text: 'first ask line one'
})
insertPart(db, {
id: 'part_1b',
messageId: 'msg_1',
sessionId: 'ses_fp',
timeCreated: 20,
text: 'first ask line two'
})
// Non-text parts of the same message must not leak into the copied prompt.
insertPart(db, {
id: 'part_1c',
messageId: 'msg_1',
sessionId: 'ses_fp',
timeCreated: 30,
type: 'tool',
text: 'tool output blob'
})
insertMessage(db, {
id: 'msg_2',
sessionId: 'ses_fp',
role: 'user',
timeCreated: 1_777_634_500_000
})
insertPart(db, {
id: 'part_2a',
messageId: 'msg_2',
sessionId: 'ses_fp',
timeCreated: 40,
text: 'a later ask'
})
db.close()
const session = await withFullFirstUserPromptCapture(() =>
parseOpenCodeSqliteSession({ dbPath: path, sessionId: 'ses_fp', platform: 'darwin' })
)
expect(session!.firstUserPrompt).toBe('first ask line one\nfirst ask line two')
})
it('skips an earliest user message that has no text parts', async () => {
const { db, path } = createTempDb()
applyOpenCodeSchema(db)
insertSession(db, {
id: 'ses_fp2',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_900_000
})
insertMessage(db, {
id: 'msg_1',
sessionId: 'ses_fp2',
role: 'user',
timeCreated: 1_777_634_000_000
})
insertPart(db, {
id: 'part_1a',
messageId: 'msg_1',
sessionId: 'ses_fp2',
timeCreated: 10,
type: 'tool',
text: 'tool only'
})
insertMessage(db, {
id: 'msg_2',
sessionId: 'ses_fp2',
role: 'user',
timeCreated: 1_777_634_500_000
})
insertPart(db, {
id: 'part_2a',
messageId: 'msg_2',
sessionId: 'ses_fp2',
timeCreated: 40,
text: 'the real typed ask'
})
db.close()
const session = await withFullFirstUserPromptCapture(() =>
parseOpenCodeSqliteSession({ dbPath: path, sessionId: 'ses_fp2', platform: 'darwin' })
)
expect(session!.firstUserPrompt).toBe('the real typed ask')
})
})
@@ -5,6 +5,10 @@ import {
finalizeSession,
updateTimeline
} from './session-scanner-accumulator'
import {
normalizeFullFirstUserPromptText,
shouldCaptureFullFirstUserPrompt
} from './session-scanner-first-user-prompt'
import { normalizeTitleText } from './session-scanner-values'
import SyncDatabase from '../sqlite/sync-database'
import { columnExists, tableExists } from '../opencode-usage/schema-helpers'
@@ -22,6 +26,8 @@ const OPENCODE_SQLITE_PREVIEW_LIMIT = 5
// id) index. Bounds the read to those messages' parts; the 15 s parse timeout
// caps the residual for a single pathological giant part.
const OPENCODE_SQLITE_PREVIEW_MESSAGE_WINDOW = 100
// Bounds a pathological single message; a real typed prompt is a handful of parts.
const FIRST_USER_PROMPT_PART_LIMIT = 512
type SessionRow = {
id: string
@@ -154,6 +160,58 @@ function extractPartText(partData: string): string | null {
}
}
function readFirstUserPromptFromOpenCodeDb(db: SyncDatabase, sessionId: string): string | null {
if (
!canCountOpenCodeMessages(db) ||
!tableExists(db, 'part') ||
!columnExists(db, 'message', 'id') ||
!columnExists(db, 'part', 'message_id') ||
!columnExists(db, 'part', 'time_created') ||
!columnExists(db, 'part', 'data')
) {
return null
}
try {
// Why: pin to the single earliest user message that actually has text parts,
// then take all of its parts. Ordering parts across every user message would
// pad a short first prompt with later turns and truncate a long one.
const rows = db
.prepare(
`SELECT p.data AS part_data
FROM part p
WHERE p.message_id = (
SELECT m.id
FROM message m
JOIN part fp ON fp.message_id = m.id
WHERE m.session_id = ?
AND json_extract(m.data, '$.role') = 'user'
AND json_extract(fp.data, '$.type') = 'text'
ORDER BY m.time_created ASC, m.id ASC
LIMIT 1
)
AND json_extract(p.data, '$.type') = 'text'
ORDER BY p.time_created ASC, p.rowid ASC
LIMIT ${FIRST_USER_PROMPT_PART_LIMIT}`
)
.all(sessionId) as { part_data: string }[]
const parts: string[] = []
for (const row of rows) {
const text = extractPartText(row.part_data)
if (text) {
parts.push(text)
}
}
if (parts.length === 0) {
return null
}
return normalizeFullFirstUserPromptText(parts.join('\n'))
} catch {
return null
}
}
function buildPreviewQuery(db: SyncDatabase): string | null {
if (
!canCountOpenCodeMessages(db) ||
@@ -254,7 +312,9 @@ export async function parseOpenCodeSqliteSession(args: {
addPreviewMessage(accumulator, {
role: mapPreviewRole(previewRow.role),
text,
timestamp: previewRow.time_created
timestamp: previewRow.time_created,
// Preview window is newest-N; first-prompt is loaded separately below.
seedFirstUserPrompt: false
})
if (previewRow.role === 'user' && !accumulator.title) {
accumulator.title =
@@ -264,6 +324,12 @@ export async function parseOpenCodeSqliteSession(args: {
}
}
// Why: list preview only joins the newest messages. On-demand copy needs the
// session's earliest real user text part, not a later turn still in the window.
if (shouldCaptureFullFirstUserPrompt()) {
accumulator.firstUserPrompt = readFirstUserPromptFromOpenCodeDb(db, sessionId)
}
return finalizeSession(accumulator, platform)
} finally {
db?.close()
@@ -127,13 +127,17 @@ export function consumeClaudeSessionLine(state: ClaudeSessionParseState, line: s
if (record.type === 'user') {
accumulator.messageCount++
const title = extractMessageText(record.message)
addPreviewContent(accumulator, 'user', asRecord(record.message)?.content, record.timestamp)
// Meta prompts (injected context) only seed the last-resort title. Some
// injected turns (task notifications) carry no isMeta, so also gate on
// the known-tag classifier — a real prompt pasting a custom `<my-element>`
// must seed the primary title, not be demoted as machinery.
const isMetaUserTurn =
record.isMeta === true || (title != null && isKnownHarnessInjectedUserTurnText(title))
addPreviewContent(accumulator, 'user', asRecord(record.message)?.content, record.timestamp, {
seedFirstUserPrompt: !isMetaUserTurn
})
if (title) {
// Meta prompts (injected context) only seed the last-resort title. Some
// injected turns (task notifications) carry no isMeta, so also gate on
// the known-tag classifier — a real prompt pasting a custom `<my-element>`
// must seed the primary title, not be demoted as machinery.
if (record.isMeta === true || isKnownHarnessInjectedUserTurnText(title)) {
if (isMetaUserTurn) {
state.metaTitle ??= title
} else {
state.firstUserTitle ??= title
@@ -18,13 +18,13 @@ import {
sessionIdFromFileName,
updateTimeline
} from './session-scanner-accumulator'
import { extractFullFirstUserPromptText } from './session-scanner-first-user-prompt'
import {
arrayValue,
asRecord,
copilotModelMetricsTotal,
extractContentText,
extractMessageText,
extractPreviewContentText,
extractString,
extractTrustedFolder,
findOpenCodeStorageRoot,
@@ -253,10 +253,12 @@ export async function consumeOpenCodeMessages(
accumulator.title ??= extractString(asRecord(message.summary)?.title)
accumulator.title ??= extractString(asRecord(message.summary)?.body)
}
// Why: pass raw body text so full first-prompt capture is not stuck on the
// 220-char preview fold (addPreviewMessage preview-caps for display).
addPreviewMessage(accumulator, {
role,
text:
extractPreviewContentText(message.content) ??
extractFullFirstUserPromptText(message.content) ??
extractString(asRecord(message.summary)?.body) ??
extractString(asRecord(message.summary)?.title),
timestamp: timeObjectValue(message.time, 'created')
@@ -42,6 +42,15 @@ export function normalizePreviewText(value: string): string | null {
return finalizeNormalizedText(normalizeStringText(value, SESSION_PREVIEW_TEXT_LIMIT))
}
/** Cut to `limit` UTF-16 code units without splitting a trailing surrogate pair. */
export function sliceAtCodeUnitLimit(value: string, limit: number): string {
if (value.length <= limit) {
return value
}
const end = limit > 0 && isHighSurrogate(value.charCodeAt(limit - 1)) ? limit - 1 : limit
return value.slice(0, end)
}
function normalizeContentText(value: unknown, limit: number): string | null {
if (typeof value === 'string') {
return finalizeNormalizedText(normalizeStringText(value, limit))
@@ -112,15 +121,18 @@ function appendInterPartSpace(builder: TextBuilder): void {
}
}
function appendNormalizedString(builder: TextBuilder, value: string): void {
function appendNormalizedString(builder: TextBuilder, value: string, maxScanLength?: number): void {
const scanEnd = maxScanLength == null ? value.length : Math.min(value.length, maxScanLength)
let index = 0
while (index < value.length && !builder.truncated) {
while (index < scanEnd && !builder.truncated) {
const hiddenBlockEnd = hiddenTextBlockEnd(value, index)
if (hiddenBlockEnd !== null) {
if (builder.text.length > 0) {
builder.pendingSpace = true
}
index = hiddenBlockEnd
// Why: hidden blocks may jump past the scan budget; clamp so multi-MB
// suppressed context cannot keep the first-prompt path busy.
index = Math.min(hiddenBlockEnd, scanEnd)
continue
}
@@ -142,9 +154,17 @@ function appendNormalizedString(builder: TextBuilder, value: string): void {
}
const charLength = codePointLength(value, index)
// Why: do not read past scanEnd mid code-point when the budget lands inside
// a surrogate pair — drop the incomplete char instead.
if (index + charLength > scanEnd) {
break
}
appendVisibleText(builder, value.slice(index, index + charLength))
index += charLength
}
if (!builder.truncated && scanEnd < value.length && builder.text.length > 0) {
builder.truncated = true
}
}
function appendVisibleText(builder: TextBuilder, value: string): void {
@@ -230,9 +250,7 @@ function isSuppressedContextPrefix(value: string): boolean {
}
function truncateWithEllipsis(value: string, limit: number): string {
const end = Math.max(0, limit - ELLIPSIS.length)
const safeEnd = end > 0 && isHighSurrogate(value.charCodeAt(end - 1)) ? end - 1 : end
return `${value.slice(0, safeEnd)}${ELLIPSIS}`
return `${sliceAtCodeUnitLimit(value, Math.max(0, limit - ELLIPSIS.length))}${ELLIPSIS}`
}
function objectRecord(value: unknown): Record<string, unknown> | null {
@@ -110,6 +110,7 @@ export type SessionAccumulator = {
messageCount: number
totalTokens: number
previewMessages: AiVaultSessionPreviewMessage[]
firstUserPrompt: string | null
lastUserPrompt: string | null
// Recoverable signal for a zero-turn transcript (see AiVaultSession).
queuedMessageCount: number
@@ -1,7 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import {
extractFullFirstUserPromptText,
extractPreviewContentText,
normalizeAgentSessionsDir,
normalizeFullFirstUserPromptText,
normalizePreviewText,
normalizeTitleText
} from './session-scanner-values'
@@ -53,6 +55,23 @@ describe('AI Vault session scanner text values', () => {
expect(result).toBe(`${'a'.repeat(216)}...`)
})
it('preserves full first-prompt text including newlines for copy', () => {
const body = `First prompt line one\n\nline two ${'word '.repeat(100)}`
expect(normalizeFullFirstUserPromptText(body)).toBe(body.trim())
expect(extractFullFirstUserPromptText([{ type: 'text', text: body }])).toBe(body.trim())
})
it('reads Codex input_text blocks and ignores tool blocks', () => {
const body = `Review the PR\n\n${'detail '.repeat(50).trimEnd()}`
expect(extractFullFirstUserPromptText([{ type: 'input_text', text: body }])).toBe(body)
expect(
extractFullFirstUserPromptText([
{ type: 'tool_result', content: 'src/main/window.ts was updated' },
{ type: 'text', text: 'Please continue the editor refactor' }
])
).toBe('Please continue the editor refactor')
})
it('expands Pi and OMP agent homes to their session directories', () => {
expect(normalizeAgentSessionsDir('/agents/.pi', '.pi')).toBe('/agents/.pi/agent/sessions')
expect(normalizeAgentSessionsDir('/agents/.pi/agent', '.pi')).toBe('/agents/.pi/agent/sessions')
+7 -1
View File
@@ -55,8 +55,14 @@ export {
extractMessageText,
extractPreviewContentText,
normalizePreviewText,
normalizeTitleText
normalizeTitleText,
sliceAtCodeUnitLimit
} from './session-scanner-text-normalization'
export {
extractFullFirstUserPromptText,
normalizeFullFirstUserPromptText,
shouldCaptureFullFirstUserPrompt
} from './session-scanner-first-user-prompt'
export function extractGitBranch(value: unknown): string | null {
const git = asRecord(value)
@@ -174,6 +174,8 @@ describe('scanAiVaultSessions', () => {
totalTokens: 155,
resumeCommand: "cd '/repo/app' && claude --resume 'claude-session'"
})
// Why: list scans omit firstUserPrompt so the vault payload stays bounded.
expect(claude?.firstUserPrompt).toBeUndefined()
const codex = result.sessions.find((session) => session.agent === 'codex')
expect(codex).toMatchObject({
@@ -185,6 +187,7 @@ describe('scanAiVaultSessions', () => {
totalTokens: 625,
resumeCommand: `cd '/repo/app/packages/web' && CODEX_HOME='${root}' codex resume '019f0000-1111-7222-8333-444444444444'`
})
expect(codex?.firstUserPrompt).toBeUndefined()
})
it('indexes Codex sessions from Orca runtime homes with resumable commands', async () => {
@@ -4,6 +4,7 @@ import {
isAzureDevOpsReviewCreationAuthenticated
} from './pull-request-creation'
import { _resetAzureDevOpsRepoRefCache } from './repository-ref'
import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe'
const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
@@ -15,7 +16,8 @@ vi.mock('../git/runner', () => ({
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock
getSshGitProvider: getSshGitProviderMock,
getSshGitProviderGeneration: () => 0
}))
vi.mock('../source-control/pull-request-template', () => ({
@@ -128,7 +130,9 @@ describe('Azure DevOps pull request creation', () => {
ok: true,
number: 38
})
expect(remoteGit.exec).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/remote/repo')
expect(remoteGit.exec).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/remote/repo', {
signal: expect.any(AbortSignal)
})
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
@@ -149,7 +153,8 @@ describe('Azure DevOps pull request creation', () => {
code: 'auth_required'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], {
cwd: '/repo'
cwd: '/repo',
timeout: REMOTE_URL_PROBE_TIMEOUT_MS
})
})
})
+8 -3
View File
@@ -16,6 +16,7 @@ import {
parseAzureDevOpsRepoRef
} from './repository-ref'
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe'
describe('parseAzureDevOpsRepoRef', () => {
beforeEach(() => {
@@ -117,7 +118,9 @@ describe('parseAzureDevOpsRepoRef', () => {
webBaseUrl: 'https://dev.azure.com/acme/Project/_git/repo'
})
expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo')
expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo', {
signal: expect.any(AbortSignal)
})
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
@@ -151,11 +154,13 @@ describe('parseAzureDevOpsRepoRef', () => {
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['remote', 'get-url', 'origin'], {
cwd: '/repo'
cwd: '/repo',
timeout: REMOTE_URL_PROBE_TIMEOUT_MS
})
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['remote', 'get-url', 'origin'], {
cwd: '/repo',
wslDistro: 'Ubuntu'
wslDistro: 'Ubuntu',
timeout: REMOTE_URL_PROBE_TIMEOUT_MS
})
})
+5 -45
View File
@@ -1,5 +1,4 @@
import { gitExecFileAsync } from '../git/runner'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import { createRemoteRefProbeCache } from '../git/remote-ref-probe-cache'
export type AzureDevOpsRepoRef = {
host: string
@@ -14,28 +13,16 @@ type LocalGitExecOptions = {
wslDistro?: string
}
const REPO_REF_CACHE_MAX_ENTRIES = 512
const repoRefCache = new Map<string, AzureDevOpsRepoRef | null>()
const repoRefProbeCache = createRemoteRefProbeCache(parseAzureDevOpsRepoRef)
/** @internal - exposed for tests only */
export function _resetAzureDevOpsRepoRefCache(): void {
repoRefCache.clear()
repoRefProbeCache.clear()
}
/** @internal - exposed for tests only */
export function _getAzureDevOpsRepoRefCacheSize(): number {
return repoRefCache.size
}
function rememberRepoRefCacheEntry(cacheKey: string, value: AzureDevOpsRepoRef | null): void {
repoRefCache.set(cacheKey, value)
while (repoRefCache.size > REPO_REF_CACHE_MAX_ENTRIES) {
const oldestKey = repoRefCache.keys().next().value
if (oldestKey === undefined) {
return
}
repoRefCache.delete(oldestKey)
}
return repoRefProbeCache.size()
}
function decodeSegment(value: string): string {
@@ -211,34 +198,7 @@ export async function getAzureDevOpsRepoRefForRemote(
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<AzureDevOpsRepoRef | null> {
const runtimeKey = connectionId ?? `local:${localGitOptions.wslDistro ?? 'host'}`
const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}`
if (repoRefCache.has(cacheKey)) {
return repoRefCache.get(cacheKey)!
}
try {
const sshGitProvider = connectionId ? getSshGitProvider(connectionId) : null
if (connectionId && !sshGitProvider) {
return null
}
const { stdout } = sshGitProvider
? await sshGitProvider.exec(['remote', 'get-url', remoteName], repoPath)
: await gitExecFileAsync(['remote', 'get-url', remoteName], {
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
})
const result = parseAzureDevOpsRepoRef(stdout)
rememberRepoRefCacheEntry(cacheKey, result)
return result
} catch {
if (connectionId) {
// Why: SSH provider failures are often transient reconnect/tunnel states;
// caching them as "not Azure DevOps" would poison the repo for the session.
return null
}
rememberRepoRefCacheEntry(cacheKey, null)
return null
}
return repoRefProbeCache.get(repoPath, remoteName, connectionId, localGitOptions)
}
export async function getAzureDevOpsRepoRef(
+10 -4
View File
@@ -17,6 +17,7 @@ import {
parseBitbucketRepoRef
} from './repository-ref'
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe'
describe('Bitbucket repository refs', () => {
beforeEach(() => {
@@ -90,7 +91,8 @@ describe('Bitbucket repository refs', () => {
})
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], {
cwd: '/repo'
cwd: '/repo',
timeout: REMOTE_URL_PROBE_TIMEOUT_MS
})
})
@@ -120,11 +122,13 @@ describe('Bitbucket repository refs', () => {
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['remote', 'get-url', 'origin'], {
cwd: '/repo'
cwd: '/repo',
timeout: REMOTE_URL_PROBE_TIMEOUT_MS
})
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['remote', 'get-url', 'origin'], {
cwd: '/repo',
wslDistro: 'Ubuntu'
wslDistro: 'Ubuntu',
timeout: REMOTE_URL_PROBE_TIMEOUT_MS
})
})
@@ -153,7 +157,9 @@ describe('Bitbucket repository refs', () => {
repoSlug: 'project'
})
expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo')
expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo', {
signal: expect.any(AbortSignal)
})
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
+5 -45
View File
@@ -1,5 +1,4 @@
import { gitExecFileAsync } from '../git/runner'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import { createRemoteRefProbeCache } from '../git/remote-ref-probe-cache'
export type BitbucketRepoRef = {
workspace: string
@@ -10,28 +9,16 @@ type LocalGitExecOptions = {
wslDistro?: string
}
const REPO_REF_CACHE_MAX_ENTRIES = 512
const repoRefCache = new Map<string, BitbucketRepoRef | null>()
const repoRefProbeCache = createRemoteRefProbeCache(parseBitbucketRepoRef)
/** @internal - exposed for tests only */
export function _resetBitbucketRepoRefCache(): void {
repoRefCache.clear()
repoRefProbeCache.clear()
}
/** @internal - exposed for tests only */
export function _getBitbucketRepoRefCacheSize(): number {
return repoRefCache.size
}
function rememberRepoRefCacheEntry(cacheKey: string, value: BitbucketRepoRef | null): void {
repoRefCache.set(cacheKey, value)
while (repoRefCache.size > REPO_REF_CACHE_MAX_ENTRIES) {
const oldestKey = repoRefCache.keys().next().value
if (oldestKey === undefined) {
return
}
repoRefCache.delete(oldestKey)
}
return repoRefProbeCache.size()
}
function decodeSegment(value: string): string {
@@ -86,34 +73,7 @@ export async function getBitbucketRepoRefForRemote(
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<BitbucketRepoRef | null> {
const runtimeKey = connectionId ?? `local:${localGitOptions.wslDistro ?? 'host'}`
const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}`
if (repoRefCache.has(cacheKey)) {
return repoRefCache.get(cacheKey)!
}
try {
const sshGitProvider = connectionId ? getSshGitProvider(connectionId) : null
if (connectionId && !sshGitProvider) {
return null
}
const { stdout } = sshGitProvider
? await sshGitProvider.exec(['remote', 'get-url', remoteName], repoPath)
: await gitExecFileAsync(['remote', 'get-url', remoteName], {
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
})
const result = parseBitbucketRepoRef(stdout)
rememberRepoRefCacheEntry(cacheKey, result)
return result
} catch {
if (connectionId) {
// Why: SSH provider failures are often transient reconnect/tunnel states;
// caching them as "not Bitbucket" would poison the repo for the session.
return null
}
rememberRepoRefCacheEntry(cacheKey, null)
return null
}
return repoRefProbeCache.get(repoPath, remoteName, connectionId, localGitOptions)
}
export async function getBitbucketRepoRef(
-41
View File
@@ -4,7 +4,6 @@ import { join, basename } from 'node:path'
import { realpath, readdir, stat } from 'node:fs/promises'
import { createReadStream } from 'node:fs'
import { createInterface } from 'node:readline'
import type { Repo } from '../../shared/types'
import type {
ClaudeUsageAttributedTurn,
ClaudeUsageDailyAggregate,
@@ -170,24 +169,6 @@ export async function listClaudeTranscriptFiles(): Promise<string[]> {
return [...new Set(files.flat())].sort()
}
export async function getProcessedFileInfo(filePath: string): Promise<ClaudeUsageProcessedFile> {
const fileStat = await stat(filePath)
let lineCount = 0
const lines = createInterface({
input: createReadStream(filePath, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const _line of lines) {
lineCount++
}
return {
path: filePath,
mtimeMs: fileStat.mtimeMs,
size: fileStat.size,
lineCount
}
}
async function getProcessedFileStat(
filePath: string
): Promise<Omit<ClaudeUsageProcessedFile, 'lineCount'>> {
@@ -757,24 +738,6 @@ export async function scanClaudeUsageFiles(
}
}
export function createWorktreeRefs(
repos: Repo[],
worktreesByRepo: Map<string, { path: string; worktreeId: string; displayName: string }[]>
): ClaudeUsageWorktreeRef[] {
const refs: ClaudeUsageWorktreeRef[] = []
for (const repo of repos) {
for (const worktree of worktreesByRepo.get(repo.id) ?? []) {
refs.push({
repoId: repo.id,
worktreeId: worktree.worktreeId,
path: worktree.path,
displayName: worktree.displayName
})
}
}
return refs
}
export function getSessionProjectLabel(locationBreakdown: ClaudeUsageLocationBreakdown[]): string {
if (locationBreakdown.length === 0) {
return 'Unknown location'
@@ -784,7 +747,3 @@ export function getSessionProjectLabel(locationBreakdown: ClaudeUsageLocationBre
}
return 'Multiple locations'
}
export function getDefaultWorktreeLabel(pathValue: string): string {
return basename(pathValue)
}
+2 -1
View File
@@ -18,7 +18,8 @@ import type { AutomationRunUsage } from '../../shared/automations-types'
import type { Store } from '../persistence'
import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata'
import type { ClaudeUsagePersistedState } from './types'
import { createWorktreeRefs, getSessionProjectLabel, scanClaudeUsageFiles } from './scanner'
import { createWorktreeRefs } from '../usage/usage-worktree-refs'
import { getSessionProjectLabel, scanClaudeUsageFiles } from './scanner'
// Why: v5 widens Claude ownership keys (message-id / uuid fallbacks). Older
// caches either lack ownership or used narrower keys and can under/over-count
+11 -11
View File
@@ -67,10 +67,10 @@ function getManagedScript(
`if not "%DEVIN_PROJECT_DIR%"=="" goto :${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`
]
: []),
// Why: call the endpoint file to refresh port/token — a PTY that survived an Orca restart carries stale env; falls through to PTY env if missing.
// Why: refresh endpoint coordinates for PTYs surviving an Orca restart.
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
...buildWindowsHookEnvironmentGuardLines(),
// Why: post via curl.exe, not PowerShell — Claude's launcher is already encoded PowerShell, so a PS post would double interpreter startups per hook.
// Why: use curl.exe to avoid an extra PowerShell startup per hook.
buildWindowsAgentHookCurlPostCommand('claude'),
'exit /b 0',
...buildWindowsHookStdinDrainEpilogue(),
@@ -89,16 +89,16 @@ function getManagedScript(
'fi'
]
: []),
// Why: source the endpoint file to refresh port/token — a PTY that survived an Orca restart carries stale env; falls back to PTY env if missing.
// Why: suppress stderr / || : so a stray parse error (TOCTOU or CRLF) can't leak into hook output or trip an outer set -e.
// Why: refresh endpoint coordinates for PTYs surviving an Orca restart.
// Why: suppress parse errors so they neither leak nor trip outer set -e.
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
'fi',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' exit 0',
'fi',
// Why: paths can hold quotes/newlines, so hand-building JSON in shell is unsafe; post the raw payload + metadata as form fields for the receiver to parse.
// Why: pipe payload to curl stdin (`payload@-`), not an inline arg, so large tool output stays off the command line (EDR false positives).
// Why: post form fields because path-bearing payloads are unsafe in hand-built JSON.
// Why: pipe payload to curl stdin to keep large output off the command line.
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
@@ -136,7 +136,7 @@ export class ClaudeHookService {
}
}
// Why: report `partial` when only some events are registered so the sidebar shows a degraded install, not a false-positive `installed`.
// Why: report partial registration instead of a false installed state.
const command = getManagedCommand(scriptPath)
const missing: string[] = []
let presentCount = 0
@@ -227,11 +227,11 @@ export class ClaudeHookService {
// Why: install the Claude hook on the remote box (via SFTP); POSIX-only by design (Windows-remote deferred).
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
// Why: remote-Windows is out of scope; ship POSIX paths. process.platform here is the local box, not the remote, so it can't gate this.
// Why: remote Windows is unsupported; local process.platform cannot identify the remote OS.
const remoteConfigPath = getRemoteConfigPath(remoteHome, this.options.settings)
const remoteScriptFileName = getPosixManagedScriptFileName(this.options.settings)
const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/${remoteScriptFileName}`
// Why: SFTP I/O fails often (network/EACCES/disk); wrap install so transient failures surface as structured state:'error' rather than an unhandled rejection.
// Why: surface fallible SFTP installs as structured errors.
try {
const config = await readHooksJsonRemote(sftp, remoteConfigPath)
if (!config) {
@@ -248,8 +248,8 @@ export class ClaudeHookService {
const command = getRemoteManagedCommand(remoteScriptPath)
const nextConfig = applyManagedHooks(config, command, remoteScriptFileName)
// Why: write script before settings — a mid-install failure then leaves a harmless orphan script, not settings.json pointing at a missing one.
// Why: SSH remotes use POSIX `.sh` paths even when Orca runs on Windows; never derive remote script syntax from the local OS.
// Why: write scripts before settings to avoid settings pointing to missing scripts.
// Why: SSH scripts always use POSIX .sh paths, regardless of the local OS.
await writeManagedScriptRemote(
sftp,
remoteScriptPath,
@@ -0,0 +1,20 @@
import type { UsageProvider } from '../usage/usage-provider-contract'
import { scanCodexUsageFiles } from './scanner'
import type { CodexUsageDailyAggregate, CodexUsagePersistedFile, CodexUsageSession } from './types'
// Why: v5 keys Codex ownership on raw token_count identity without session id
// so forks that rewrite session_meta still match. Older caches used session-
// scoped keys and can double-count after fork/resume (#8006).
export const CODEX_USAGE_SCHEMA_VERSION = 5
export const codexUsageProvider = {
id: 'codex',
label: 'Codex',
schemaVersion: CODEX_USAGE_SCHEMA_VERSION,
scan: scanCodexUsageFiles
} satisfies UsageProvider<
'processedFiles',
CodexUsagePersistedFile,
CodexUsageSession,
CodexUsageDailyAggregate
>
+27 -396
View File
@@ -3,30 +3,29 @@ import { basename, join, win32, posix } from 'node:path'
import { createReadStream, existsSync } from 'node:fs'
import { realpath, readdir, stat } from 'node:fs/promises'
import { createInterface } from 'node:readline'
import type { Repo } from '../../shared/types'
import { areWorktreePathsEqual } from '../ipc/worktree-logic'
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths'
import { getCodexAccountHomeSessionDirectories } from '../codex/codex-account-home-discovery'
import { getLegacyCopiedCodexSessionBridgeScanPreference } from '../codex/codex-session-bridge'
import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer'
import { createUsageEventAggregation } from '../usage/usage-event-aggregation'
import {
looksLikeWindowsPath,
normalizeComparablePath,
normalizeFsPath
} from '../usage/usage-path-comparison'
import { ensureNumber, extractString } from '../usage/usage-record-coercion'
import type { UsageScanWorktreeRef } from '../usage/usage-provider-contract'
import type {
CodexUsageAttributedEvent,
CodexUsageDailyAggregate,
CodexUsageLocationBreakdown,
CodexUsageLocationModelBreakdown,
CodexUsageModelBreakdown,
CodexUsageParsedEvent,
CodexUsagePersistedFile,
CodexUsageProcessedFile,
CodexUsageSession
} from './types'
export type CodexUsageWorktreeRef = {
repoId: string
worktreeId: string
path: string
displayName: string
}
export type CodexUsageWorktreeRef = UsageScanWorktreeRef
type CodexUsageRawRecord = {
timestamp?: string
@@ -58,28 +57,6 @@ type CodexUsageDeltaResolution =
const YIELD_EVERY_FILES = 10
const YIELD_EVERY_DISCOVERY_ENTRIES = 100
function ensureNumber(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
function normalizeComparablePath(pathValue: string, platform = process.platform): string {
const normalized = pathValue.replace(/\\/g, '/')
return platform === 'win32' || looksLikeWindowsPath(pathValue)
? normalized.toLowerCase()
: normalized
}
function normalizeFsPath(pathValue: string, platform = process.platform): string {
if (platform === 'win32' || looksLikeWindowsPath(pathValue)) {
return win32.normalize(win32.resolve(pathValue))
}
return posix.normalize(posix.resolve(pathValue))
}
function looksLikeWindowsPath(pathValue: string): boolean {
return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\')
}
async function canonicalizePath(pathValue: string): Promise<string> {
try {
const resolved = await realpath(pathValue)
@@ -414,14 +391,6 @@ function buildCodexUsageEventKey(
return [timestamp, tupleOf(totalUsage), tupleOf(lastUsage)].join('|')
}
function extractString(value: unknown): string | null {
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
function extractModel(value: unknown): string | null {
if (value == null || typeof value !== 'object') {
return null
@@ -568,331 +537,29 @@ export async function attributeCodexUsageEvent(
}
}
function createEmptySession(event: CodexUsageAttributedEvent): CodexUsageSession {
return {
sessionId: event.sessionId,
firstTimestamp: event.timestamp,
lastTimestamp: event.timestamp,
primaryModel: event.model,
hasMixedModels: false,
primaryProjectLabel: event.projectLabel,
hasMixedLocations: false,
primaryWorktreeId: event.worktreeId,
primaryRepoId: event.repoId,
eventCount: 0,
totalInputTokens: 0,
totalCachedInputTokens: 0,
totalOutputTokens: 0,
totalReasoningOutputTokens: 0,
totalTokens: 0,
hasInferredPricing: false,
locationBreakdown: [],
modelBreakdown: [],
locationModelBreakdown: []
}
}
type CodexUsageMetric = { hasInferredPricing: boolean }
function createEmptyDailyAggregate(event: CodexUsageAttributedEvent): CodexUsageDailyAggregate {
return {
day: event.day,
model: event.model,
projectKey: event.projectKey,
projectLabel: event.projectLabel,
repoId: event.repoId,
worktreeId: event.worktreeId,
eventCount: 0,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
reasoningOutputTokens: 0,
totalTokens: 0,
hasInferredPricing: false
}
}
function mergeLocationBreakdown(
target: CodexUsageLocationBreakdown[],
event: CodexUsageAttributedEvent
): void {
const existing = target.find((entry) => entry.locationKey === event.projectKey) ?? null
if (existing) {
existing.eventCount++
existing.inputTokens += event.inputTokens
existing.cachedInputTokens += event.cachedInputTokens
existing.outputTokens += event.outputTokens
existing.reasoningOutputTokens += event.reasoningOutputTokens
existing.totalTokens += event.totalTokens
existing.hasInferredPricing ||= event.hasInferredPricing
return
}
target.push({
locationKey: event.projectKey,
projectLabel: event.projectLabel,
repoId: event.repoId,
worktreeId: event.worktreeId,
eventCount: 1,
inputTokens: event.inputTokens,
cachedInputTokens: event.cachedInputTokens,
outputTokens: event.outputTokens,
reasoningOutputTokens: event.reasoningOutputTokens,
totalTokens: event.totalTokens,
hasInferredPricing: event.hasInferredPricing
})
}
function mergeModelBreakdown(
target: CodexUsageModelBreakdown[],
event: CodexUsageAttributedEvent
): void {
const key = event.model ?? 'unknown'
const existing = target.find((entry) => entry.modelKey === key) ?? null
if (existing) {
existing.eventCount++
existing.inputTokens += event.inputTokens
existing.cachedInputTokens += event.cachedInputTokens
existing.outputTokens += event.outputTokens
existing.reasoningOutputTokens += event.reasoningOutputTokens
existing.totalTokens += event.totalTokens
existing.hasInferredPricing ||= event.hasInferredPricing
return
}
target.push({
modelKey: key,
modelLabel: event.model ?? 'Unknown model',
eventCount: 1,
inputTokens: event.inputTokens,
cachedInputTokens: event.cachedInputTokens,
outputTokens: event.outputTokens,
reasoningOutputTokens: event.reasoningOutputTokens,
totalTokens: event.totalTokens,
hasInferredPricing: event.hasInferredPricing
})
}
function mergeLocationModelBreakdown(
target: CodexUsageLocationModelBreakdown[],
event: CodexUsageAttributedEvent
): void {
const modelKey = event.model ?? 'unknown'
const existing =
target.find((entry) => entry.locationKey === event.projectKey && entry.modelKey === modelKey) ??
null
if (existing) {
existing.eventCount++
existing.inputTokens += event.inputTokens
existing.cachedInputTokens += event.cachedInputTokens
existing.outputTokens += event.outputTokens
existing.reasoningOutputTokens += event.reasoningOutputTokens
existing.totalTokens += event.totalTokens
existing.hasInferredPricing ||= event.hasInferredPricing
return
}
target.push({
locationKey: event.projectKey,
modelKey,
modelLabel: event.model ?? 'Unknown model',
repoId: event.repoId,
worktreeId: event.worktreeId,
eventCount: 1,
inputTokens: event.inputTokens,
cachedInputTokens: event.cachedInputTokens,
outputTokens: event.outputTokens,
reasoningOutputTokens: event.reasoningOutputTokens,
totalTokens: event.totalTokens,
hasInferredPricing: event.hasInferredPricing
})
}
function aggregateCodexUsage(events: CodexUsageAttributedEvent[]): {
sessions: CodexUsageSession[]
dailyAggregates: CodexUsageDailyAggregate[]
} {
const sessionsById = new Map<string, CodexUsageSession>()
const dailyByKey = new Map<string, CodexUsageDailyAggregate>()
for (const event of events) {
const session = sessionsById.get(event.sessionId) ?? createEmptySession(event)
if (!sessionsById.has(event.sessionId)) {
sessionsById.set(event.sessionId, session)
const codexUsageAggregation = createUsageEventAggregation<
CodexUsageAttributedEvent,
CodexUsageMetric
>({
metric: {
empty: () => ({ hasInferredPricing: false }),
fromEvent: (event) => ({ hasInferredPricing: event.hasInferredPricing }),
fold: (target, source) => {
target.hasInferredPricing ||= source.hasInferredPricing
}
if (event.timestamp < session.firstTimestamp) {
session.firstTimestamp = event.timestamp
}
if (event.timestamp >= session.lastTimestamp) {
session.lastTimestamp = event.timestamp
}
session.eventCount++
session.totalInputTokens += event.inputTokens
session.totalCachedInputTokens += event.cachedInputTokens
session.totalOutputTokens += event.outputTokens
session.totalReasoningOutputTokens += event.reasoningOutputTokens
session.totalTokens += event.totalTokens
session.hasInferredPricing ||= event.hasInferredPricing
mergeLocationBreakdown(session.locationBreakdown, event)
mergeModelBreakdown(session.modelBreakdown, event)
mergeLocationModelBreakdown(session.locationModelBreakdown, event)
const dailyKey = [event.day, event.model ?? 'unknown', event.projectKey].join('::')
const daily = dailyByKey.get(dailyKey) ?? createEmptyDailyAggregate(event)
if (!dailyByKey.has(dailyKey)) {
dailyByKey.set(dailyKey, daily)
}
daily.eventCount++
daily.inputTokens += event.inputTokens
daily.cachedInputTokens += event.cachedInputTokens
daily.outputTokens += event.outputTokens
daily.reasoningOutputTokens += event.reasoningOutputTokens
daily.totalTokens += event.totalTokens
daily.hasInferredPricing ||= event.hasInferredPricing
}
return {
sessions: finalizeSessions(sessionsById),
dailyAggregates: [...dailyByKey.values()].sort((left, right) =>
left.day === right.day
? left.projectLabel.localeCompare(right.projectLabel)
: left.day.localeCompare(right.day)
)
}
}
function finalizeSessions(sessionsById: Map<string, CodexUsageSession>): CodexUsageSession[] {
for (const session of sessionsById.values()) {
session.locationBreakdown.sort((left, right) => right.totalTokens - left.totalTokens)
session.modelBreakdown.sort((left, right) => right.totalTokens - left.totalTokens)
const primaryLocation = session.locationBreakdown[0] ?? null
const primaryModel = session.modelBreakdown[0] ?? null
session.primaryProjectLabel =
session.locationBreakdown.length <= 1
? (primaryLocation?.projectLabel ?? 'Unknown location')
: 'Multiple locations'
session.hasMixedLocations = session.locationBreakdown.length > 1
session.primaryWorktreeId = primaryLocation?.worktreeId ?? null
session.primaryRepoId = primaryLocation?.repoId ?? null
session.primaryModel =
session.modelBreakdown.length <= 1 ? (primaryModel?.modelLabel ?? null) : 'Mixed models'
session.hasMixedModels = session.modelBreakdown.length > 1
}
return [...sessionsById.values()].sort((left, right) =>
right.lastTimestamp.localeCompare(left.lastTimestamp)
)
}
function mergeSessions(
target: Map<string, CodexUsageSession>,
sessions: CodexUsageSession[]
): void {
for (const session of sessions) {
const existing = target.get(session.sessionId)
if (!existing) {
target.set(session.sessionId, cloneSessionForMerge(session))
continue
}
existing.firstTimestamp =
session.firstTimestamp < existing.firstTimestamp
? session.firstTimestamp
: existing.firstTimestamp
existing.lastTimestamp =
session.lastTimestamp > existing.lastTimestamp
? session.lastTimestamp
: existing.lastTimestamp
existing.eventCount += session.eventCount
existing.totalInputTokens += session.totalInputTokens
existing.totalCachedInputTokens += session.totalCachedInputTokens
existing.totalOutputTokens += session.totalOutputTokens
existing.totalReasoningOutputTokens += session.totalReasoningOutputTokens
existing.totalTokens += session.totalTokens
existing.hasInferredPricing ||= session.hasInferredPricing
for (const location of session.locationBreakdown) {
const existingLocation =
existing.locationBreakdown.find((entry) => entry.locationKey === location.locationKey) ??
null
if (existingLocation) {
existingLocation.eventCount += location.eventCount
existingLocation.inputTokens += location.inputTokens
existingLocation.cachedInputTokens += location.cachedInputTokens
existingLocation.outputTokens += location.outputTokens
existingLocation.reasoningOutputTokens += location.reasoningOutputTokens
existingLocation.totalTokens += location.totalTokens
existingLocation.hasInferredPricing ||= location.hasInferredPricing
} else {
existing.locationBreakdown.push({ ...location })
}
}
for (const model of session.modelBreakdown) {
const existingModel =
existing.modelBreakdown.find((entry) => entry.modelKey === model.modelKey) ?? null
if (existingModel) {
existingModel.eventCount += model.eventCount
existingModel.inputTokens += model.inputTokens
existingModel.cachedInputTokens += model.cachedInputTokens
existingModel.outputTokens += model.outputTokens
existingModel.reasoningOutputTokens += model.reasoningOutputTokens
existingModel.totalTokens += model.totalTokens
existingModel.hasInferredPricing ||= model.hasInferredPricing
} else {
existing.modelBreakdown.push({ ...model })
}
}
for (const locationModel of session.locationModelBreakdown) {
const existingLocationModel =
existing.locationModelBreakdown.find(
(entry) =>
entry.locationKey === locationModel.locationKey &&
entry.modelKey === locationModel.modelKey
) ?? null
if (existingLocationModel) {
existingLocationModel.eventCount += locationModel.eventCount
existingLocationModel.inputTokens += locationModel.inputTokens
existingLocationModel.cachedInputTokens += locationModel.cachedInputTokens
existingLocationModel.outputTokens += locationModel.outputTokens
existingLocationModel.reasoningOutputTokens += locationModel.reasoningOutputTokens
existingLocationModel.totalTokens += locationModel.totalTokens
existingLocationModel.hasInferredPricing ||= locationModel.hasInferredPricing
} else {
existing.locationModelBreakdown.push({ ...locationModel })
}
}
}
}
function cloneSessionForMerge(session: CodexUsageSession): CodexUsageSession {
return {
},
cloneSessionForMerge: (session) => ({
...session,
locationBreakdown: session.locationBreakdown.map((entry) => ({ ...entry })),
modelBreakdown: session.modelBreakdown.map((entry) => ({ ...entry })),
locationModelBreakdown: session.locationModelBreakdown.map((entry) => ({ ...entry }))
}
}
})
})
function mergeDailyAggregates(
target: Map<string, CodexUsageDailyAggregate>,
dailyAggregates: CodexUsageDailyAggregate[]
): void {
for (const aggregate of dailyAggregates) {
const key = [aggregate.day, aggregate.model ?? 'unknown', aggregate.projectKey].join('::')
const existing = target.get(key)
if (!existing) {
target.set(key, { ...aggregate })
continue
}
existing.eventCount += aggregate.eventCount
existing.inputTokens += aggregate.inputTokens
existing.cachedInputTokens += aggregate.cachedInputTokens
existing.outputTokens += aggregate.outputTokens
existing.reasoningOutputTokens += aggregate.reasoningOutputTokens
existing.totalTokens += aggregate.totalTokens
existing.hasInferredPricing ||= aggregate.hasInferredPricing
}
}
const { finalizeSessions, mergeSessions, mergeDailyAggregates, sortDailyAggregates } =
codexUsageAggregation
export function parseCodexUsageRecord(
line: string,
@@ -1042,7 +709,7 @@ export async function parseCodexUsageFile(
return {
...processedFile,
...aggregateCodexUsage(events),
...codexUsageAggregation.aggregate(events),
ownedEventKeys: [...ownedEventKeys],
hasDeferredClaims
}
@@ -1154,42 +821,6 @@ export async function scanCodexUsageFiles(
return {
processedFiles,
sessions: finalizeSessions(sessionsById),
dailyAggregates: [...dailyByKey.values()].sort((left, right) =>
left.day === right.day
? left.projectLabel.localeCompare(right.projectLabel)
: left.day.localeCompare(right.day)
)
dailyAggregates: sortDailyAggregates(dailyByKey)
}
}
export function createWorktreeRefs(
repos: Repo[],
worktreesByRepo: Map<string, { path: string; worktreeId: string; displayName: string }[]>
): CodexUsageWorktreeRef[] {
const refs: CodexUsageWorktreeRef[] = []
for (const repo of repos) {
for (const worktree of worktreesByRepo.get(repo.id) ?? []) {
refs.push({
repoId: repo.id,
worktreeId: worktree.worktreeId,
path: worktree.path,
displayName: worktree.displayName
})
}
}
return refs
}
export function getDefaultWorktreeLabel(pathValue: string): string {
return basename(pathValue)
}
export function getSessionProjectLabel(locationBreakdown: CodexUsageLocationBreakdown[]): string {
if (locationBreakdown.length === 0) {
return 'Unknown location'
}
if (locationBreakdown.length === 1) {
return locationBreakdown[0].projectLabel
}
return 'Multiple locations'
}
-1
View File
@@ -52,7 +52,6 @@ vi.mock('node:fs/promises', async () => {
})
vi.mock('./scanner', () => ({
createWorktreeRefs: vi.fn(() => []),
scanCodexUsageFiles: vi.fn()
}))
+4 -6
View File
@@ -18,12 +18,10 @@ import type { AutomationRunUsage } from '../../shared/automations-types'
import type { Store } from '../persistence'
import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata'
import type { CodexUsagePersistedState } from './types'
import { createWorktreeRefs, scanCodexUsageFiles } from './scanner'
import { createWorktreeRefs } from '../usage/usage-worktree-refs'
import { CODEX_USAGE_SCHEMA_VERSION, codexUsageProvider } from './codex-usage-provider'
// Why: v5 keys Codex ownership on raw token_count identity without session id
// so forks that rewrite session_meta still match. Older caches used session-
// scoped keys and can double-count after fork/resume (#8006).
const SCHEMA_VERSION = 5
const SCHEMA_VERSION = CODEX_USAGE_SCHEMA_VERSION
const STALE_MS = 5 * 60_000
const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000
@@ -467,7 +465,7 @@ export class CodexUsageStore {
const repos = this.store.getRepos()
const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo)
const result = await scanCodexUsageFiles(
const result = await codexUsageProvider.scan(
createWorktreeRefs(repos, worktreesByRepo),
this.state.worktreeFingerprint === worktreeFingerprint ? this.state.processedFiles : []
)
@@ -25,20 +25,3 @@ export function resolveMacOSComputerUseExecutablePath(): string | null {
const executablePath = join(appPath, 'Contents', 'MacOS', 'orca-computer-use-macos')
return existsSync(executablePath) ? executablePath : null
}
export function resolveMacOSNativeProviderPath(): string | null {
const override = process.env.ORCA_COMPUTER_MACOS_PROVIDER_PATH
if (override && existsSync(override)) {
return override
}
const packaged = [join(process.resourcesPath ?? '', 'computer-use-macos/orca-computer-use-macos')]
const dev = [
join(process.cwd(), 'native/computer-use-macos/.build/debug/orca-computer-use-macos'),
join(process.cwd(), 'native/computer-use-macos/.build/release/orca-computer-use-macos'),
resolve(__dirname, '../../native/computer-use-macos/.build/debug/orca-computer-use-macos')
]
const candidates = process.resourcesPath ? [...packaged, ...dev] : dev
return candidates.find((candidate) => candidate && existsSync(candidate)) ?? null
}
+10 -10
View File
@@ -26,8 +26,8 @@ import {
buildWindowsHookStdinDrainEpilogue
} from '../agent-hooks/hook-stdin-contract'
// cursor-agent's declarative hooks surface (https://cursor.com/docs/hooks); subscribe to the minimum set for spinner + turn detection.
// sessionStart/sessionEnd are NOT subscribed: they fire at process (not turn) boundaries and can race/reset the just-submitted turn's prompt cache.
// Subscribe only to Cursor hooks needed for spinner and turn detection.
// Exclude process-boundary session hooks, which can reset the submitted-turn prompt cache.
const CURSOR_EVENTS = [
'beforeSubmitPrompt',
'stop',
@@ -62,7 +62,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
return [
'@echo off',
'setlocal',
// Why: source the endpoint file so a surviving PTY reaches the current server, not the prior Orca's coordinates (see claude/hook-service.ts).
// Why: source current endpoint coordinates for PTYs surviving an Orca restart.
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
...buildWindowsHookEnvironmentGuardLines(),
buildWindowsAgentHookPostCommand('cursor'),
@@ -75,15 +75,15 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
return [
'#!/bin/sh',
...buildPosixHookPayloadCapture(),
// Why: sourcing refreshes PORT/TOKEN/ENV from the current Orca so a surviving PTY keeps reporting after a restart (see claude/hook-service.ts).
// Why: refresh endpoint coordinates so surviving PTYs keep reporting.
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
'fi',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' exit 0',
'fi',
// Why: worktreeId embeds a path, so hand-building JSON in shell is unsafe (quotes/newlines); post raw payload as form fields instead.
// Why: pipe payload via curl stdin (`payload@-`), not an inline arg, so large tool output stays off the command line (EDR false positives).
// Why: post form fields because path-bearing worktree IDs are unsafe in hand-built JSON.
// Why: pipe payload to curl stdin to keep large output off the command line.
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/cursor" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
@@ -167,7 +167,7 @@ export class CursorHookService {
const nextHooks = { ...config.hooks }
const managedEvents = new Set<string>(CURSOR_EVENTS)
// Why: match by script filename (not exact command) so installs sweep stale entries from older builds or a different userData path.
// Why: match filenames to remove stale hooks from prior builds and user-data paths.
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
// Why: sweep managed entries from events we no longer subscribe to, else upgraded users keep firing stale hooks.
@@ -192,7 +192,7 @@ export class CursorHookService {
for (const eventName of CURSOR_EVENTS) {
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
// Sweep both Claude-shaped (hooks[].command) and Cursor-shaped (definition.command) variants so installs converge on one entry.
// Sweep Claude- and Cursor-shaped variants so installs converge on one entry.
const cleaned = removeManagedCommands(current, isManagedCommand).filter(
(definition) => !isManagedCommand(definition.command as string | undefined)
)
@@ -201,7 +201,7 @@ export class CursorHookService {
nextHooks[eventName] = [...cleaned, definition]
}
// Why: cursor-agent's schema requires top-level `version: 1` (https://cursor.com/docs/hooks); keep any user-pinned value.
// Why: Cursor requires `version: 1`; preserve user-pinned values.
const nextConfig: Record<string, unknown> = { ...config, hooks: nextHooks }
if (nextConfig.version === undefined) {
nextConfig.version = 1
@@ -247,7 +247,7 @@ export class CursorHookService {
}
// Why: script-then-config order so a partial mid-install leaves a working script nothing points at.
// Why: SSH remotes always use POSIX `.sh` hook paths even when Orca runs on Windows; never derive from local OS.
// Why: SSH hooks always use POSIX .sh paths, regardless of the local OS.
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig)
@@ -1,89 +0,0 @@
import type { TerminalModes } from './types'
// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l.
// Keep parser state far beyond normal mode lists while still bounding memory.
const PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096
type MouseTrackingMode = NonNullable<TerminalModes['mouseTrackingMode']>
// Why: @xterm/headless doesn't expose DECSET mouse/SGR encoding state, so the
// emulator mirrors it by scanning the raw byte stream; snapshots restore these
// modes on rehydrate.
export class TerminalPrivateModeTracker {
private scanTail = ''
mouseTrackingMode: MouseTrackingMode = 'none'
sgrMouseMode = false
sgrMousePixelsMode = false
scan(data: string): void {
const input = this.scanTail + data
this.scanTail = this.extractScanTail(input)
// oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars
const privateModeRe = /\x1bc|\x1b\[\?([0-9;]+)([hl])|\x9b\?([0-9;]+)([hl])/g
let match: RegExpExecArray | null
while ((match = privateModeRe.exec(input)) !== null) {
if (match[0] === '\x1bc') {
this.mouseTrackingMode = 'none'
this.sgrMouseMode = false
this.sgrMousePixelsMode = false
continue
}
const params = match[1] ?? match[3]
const enabled = (match[2] ?? match[4]) === 'h'
for (const rawParam of params.split(';')) {
if (rawParam === '') {
continue
}
const param = Number(rawParam)
if (!Number.isInteger(param)) {
continue
}
if (param === 9) {
this.mouseTrackingMode = enabled ? 'x10' : 'none'
}
if (param === 1000) {
this.mouseTrackingMode = enabled ? 'vt200' : 'none'
}
if (param === 1002) {
this.mouseTrackingMode = enabled ? 'drag' : 'none'
}
if (param === 1003) {
this.mouseTrackingMode = enabled ? 'any' : 'none'
}
if (param === 1006) {
this.sgrMouseMode = enabled
this.sgrMousePixelsMode = false
}
if (param === 1016) {
this.sgrMouseMode = false
this.sgrMousePixelsMode = enabled
}
}
}
}
private extractScanTail(input: string): string {
const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b'))
if (start === -1) {
return ''
}
const tail = input.slice(start)
if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) {
return ''
}
if (tail === '\x1b' || tail === '\x1b[' || tail === '\x9b') {
return tail
}
if (tail.startsWith('\x1b[?')) {
return this.isIncompleteParams(tail.slice(3)) ? tail : ''
}
if (tail.startsWith('\x9b?')) {
return this.isIncompleteParams(tail.slice(2)) ? tail : ''
}
return ''
}
private isIncompleteParams(params: string): boolean {
return /^[0-9;]*$/.test(params)
}
}
+17 -6
View File
@@ -4,7 +4,7 @@
// hour's loss; fsync stops it from happening.
import { closeSync, fsyncSync, openSync, renameSync, writeFileSync } from 'node:fs'
import { open, readdir, rename, rm } from 'node:fs/promises'
import { open, readdir, rename, rm, stat } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
/**
@@ -103,11 +103,13 @@ export function durableWriteTempPath(finalPath: string): string {
/**
* Sweep temp files orphaned by a death between write and rename — for multi-MB payloads they would
* otherwise accumulate forever. Racing another instance's in-flight save at worst loses that save,
* the trade already accepted for rename-based atomicity. This process's own temps are skipped: a
* `<file>.<our pid>.*.tmp` seen during a sweep is a live write, and deleting it fails its rename.
* otherwise accumulate forever. Callers can require a minimum age to spare another live instance's
* write. This process's own temps are always skipped because deleting one would fail its rename.
*/
export async function removeStaleDurableWriteTempFiles(finalPath: string): Promise<void> {
export async function removeStaleDurableWriteTempFiles(
finalPath: string,
options: { minimumAgeMs?: number } = {}
): Promise<void> {
const directory = dirname(finalPath)
const prefix = `${basename(finalPath)}.`
const ownPrefix = `${prefix}${process.pid}.`
@@ -118,7 +120,16 @@ export async function removeStaleDurableWriteTempFiles(finalPath: string): Promi
.filter(
(name) => name.startsWith(prefix) && name.endsWith('.tmp') && !name.startsWith(ownPrefix)
)
.map((name) => rm(join(directory, name), { force: true }).catch(() => {}))
.map(async (name) => {
const path = join(directory, name)
if (options.minimumAgeMs) {
const info = await stat(path).catch(() => null)
if (!info || Date.now() - info.mtimeMs < options.minimumAgeMs) {
return
}
}
await rm(path, { force: true }).catch(() => {})
})
)
} catch {
// Directory missing or unreadable — nothing to sweep.
+44
View File
@@ -0,0 +1,44 @@
import { basename, win32 } from 'node:path'
export function stripMatchingQuotes(value: string): string {
const trimmed = value.trim()
const quote = trimmed[0]
if ((quote === '"' || quote === "'") && trimmed.endsWith(quote)) {
return trimmed.slice(1, -1)
}
return trimmed
}
export function hasMatchingOuterQuotes(value: string): boolean {
const trimmed = value.trim()
const quote = trimmed[0]
return (quote === '"' || quote === "'") && trimmed.endsWith(quote)
}
function getLeadingShellCommandToken(command: string): string {
const trimmed = command.trim()
const quote = trimmed[0]
if (quote === '"' || quote === "'") {
const closingIndex = trimmed.indexOf(quote, 1)
if (closingIndex > 0) {
return trimmed.slice(1, closingIndex)
}
}
return trimmed.split(/\s+/, 1)[0] ?? ''
}
/** Lowercased launcher name without directory or Windows executable extension. */
export function getLauncherBaseName(
command: string,
options: { shellCommand?: boolean } = {}
): string {
const normalized = options.shellCommand
? getLeadingShellCommandToken(command)
: stripMatchingQuotes(command)
const name = normalized.includes('\\') ? win32.basename(normalized) : basename(normalized)
return name.replace(/\.(?:cmd|exe|bat)$/i, '').toLowerCase()
}
export function isWindowsBatchLauncher(command: string): boolean {
return /\.(?:cmd|bat)$/i.test(stripMatchingQuotes(command))
}
@@ -29,14 +29,6 @@ export function normalizedToDevicePixels(
}
}
export type AndroidHardwareButton =
| 'home'
| 'back'
| 'recents'
| 'power'
| 'volume_up'
| 'volume_down'
// Android KeyEvent KEYCODE_* values, including the common aliases agents use.
const BUTTON_KEYCODES: Record<string, number> = {
home: 3,
-6
View File
@@ -14,9 +14,3 @@ export type EmulatorSessionInfo = {
streamCodec?: EmulatorStreamCodec
backend?: EmulatorBackendKind
}
export type EmulatorCliTarget = {
worktreeId?: string
deviceUdid?: string
emulatorId?: string // Orca-generated id from list (for stability, like browserPageId)
}
+176 -1
View File
@@ -32,6 +32,168 @@ describe('resolveExternalEditorLaunchSpec', () => {
})
})
it('prefers the JetBrains *64.exe colocated with the resolved idea.cmd on Windows', () => {
const installBin = 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin'
resolveCliCommandMock.mockImplementation((command: string) =>
command === 'idea' ? `${installBin}\\idea.cmd` : command
)
expect(
resolveExternalEditorLaunchSpec('idea', 'C:\\workspaces\\orca', {
platform: 'win32',
fileExists: (candidate) => candidate === `${installBin}\\idea64.exe`
})
).toEqual({
kind: 'executable',
hideWindowsConsole: true,
spawnCmd: `${installBin}\\idea64.exe`,
spawnArgs: ['C:\\workspaces\\orca']
})
// Why: a bare PATH `idea64` may belong to a different, stale install.
expect(resolveCliCommandMock).toHaveBeenCalledWith('idea', { platform: 'win32' })
expect(resolveCliCommandMock).not.toHaveBeenCalledWith('idea64', expect.anything())
})
it('prefers a colocated .exe over an idea64.cmd shim when the user names idea64', () => {
const installBin = 'C:\\Program Files\\JetBrains\\GoLand\\bin'
resolveCliCommandMock.mockImplementation((command: string) =>
command === 'goland64' ? `${installBin}\\goland64.cmd` : command
)
expect(
resolveExternalEditorLaunchSpec('goland64', 'C:\\workspaces\\orca', {
platform: 'win32',
fileExists: (candidate) => candidate === `${installBin}\\goland64.exe`
}).spawnCmd
).toBe(`${installBin}\\goland64.exe`)
})
it('keeps the Toolbox idea.cmd shim and detaches it when no GUI exe sits beside it', () => {
const toolboxShim = 'C:\\Users\\me\\AppData\\Local\\JetBrains\\Toolbox\\scripts\\idea.cmd'
resolveCliCommandMock.mockImplementation((command: string) =>
command === 'idea' ? toolboxShim : command
)
expect(
resolveExternalEditorLaunchSpec('idea', 'C:\\workspaces\\orca', {
platform: 'win32',
fileExists: () => false
})
).toEqual({
kind: 'executable',
hideWindowsConsole: true,
detachedGui: true,
spawnCmd: toolboxShim,
spawnArgs: ['C:\\workspaces\\orca']
})
})
it('detaches a directly configured JetBrains shim path when no GUI exe is beside it', () => {
expect(
resolveExternalEditorLaunchSpec('C:\\Tools\\WebStorm\\bin\\webstorm.bat', 'C:\\ws', {
platform: 'win32',
fileExists: () => false
})
).toEqual({
kind: 'executable',
hideWindowsConsole: true,
detachedGui: true,
spawnCmd: 'C:\\Tools\\WebStorm\\bin\\webstorm.bat',
spawnArgs: ['C:\\ws']
})
})
it('upgrades a direct JetBrains .cmd path to the colocated *64.exe', () => {
const installBin = 'C:\\Program Files\\JetBrains\\WebStorm\\bin'
expect(
resolveExternalEditorLaunchSpec(`${installBin}\\webstorm.cmd`, 'C:\\ws', {
platform: 'win32',
fileExists: (candidate) => candidate === `${installBin}\\webstorm64.exe`
})
).toEqual({
kind: 'executable',
hideWindowsConsole: true,
spawnCmd: `${installBin}\\webstorm64.exe`,
spawnArgs: ['C:\\ws']
})
})
it('upgrades a short-name console idea.exe stub to the colocated idea64.exe', () => {
const installBin = 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin'
expect(
resolveExternalEditorLaunchSpec(`${installBin}\\idea.exe`, 'C:\\ws', {
platform: 'win32',
fileExists: (candidate) => candidate === `${installBin}\\idea64.exe`
})
).toEqual({
kind: 'executable',
hideWindowsConsole: true,
spawnCmd: `${installBin}\\idea64.exe`,
spawnArgs: ['C:\\ws']
})
})
it('leaves a direct idea64.exe path unchanged even when a sibling exists', () => {
const guiExe = 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin\\idea64.exe'
expect(
resolveExternalEditorLaunchSpec(guiExe, 'C:\\ws', {
platform: 'win32',
fileExists: () => true
})
).toEqual({
kind: 'executable',
hideWindowsConsole: true,
spawnCmd: guiExe,
spawnArgs: ['C:\\ws']
})
})
it('does not rewrite non-JetBrains .exe launchers via colocation', () => {
const codeExe = 'C:\\Program Files\\Microsoft VS Code\\Code.exe'
expect(
resolveExternalEditorLaunchSpec(codeExe, 'C:\\ws', {
platform: 'win32',
fileExists: () => true
}).spawnCmd
).toBe(codeExe)
})
it('keeps idea.exe when no colocated idea64.exe exists', () => {
const consoleExe = 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin\\idea.exe'
expect(
resolveExternalEditorLaunchSpec(consoleExe, 'C:\\ws', {
platform: 'win32',
fileExists: () => false
})
).toEqual({
kind: 'executable',
hideWindowsConsole: true,
spawnCmd: consoleExe,
spawnArgs: ['C:\\ws']
})
})
// Why: `start` re-parses argv, so only JetBrains shims may take that path.
it.each([
['code', 'C:\\Tools\\code.cmd', ['C:\\workspaces\\orca']],
[
'cursor',
'C:\\Users\\me\\AppData\\Local\\Programs\\cursor\\bin\\cursor.cmd',
['--new-window', 'C:\\workspaces\\orca']
]
])('does not detach the %s batch shim through start', (command, resolvedCommand, spawnArgs) => {
resolveCliCommandMock.mockImplementation(() => resolvedCommand)
expect(
resolveExternalEditorLaunchSpec(command, 'C:\\workspaces\\orca', { platform: 'win32' })
).toEqual({
kind: 'executable',
hideWindowsConsole: true,
spawnCmd: resolvedCommand,
spawnArgs
})
})
it('appends escaped paths to compound macOS open commands', () => {
expect(
resolveExternalEditorLaunchSpec('open -a "Typora"', "/tmp/note's.md", {
@@ -85,6 +247,19 @@ describe('resolveExternalEditorLaunchSpec', () => {
})
})
it('runs GUI compound Windows commands verbatim instead of re-parsing them under start', () => {
expect(
resolveExternalEditorLaunchSpec('code --reuse-window', 'C:\\workspaces\\orca', {
platform: 'win32'
})
).toEqual({
kind: 'shell',
hideWindowsConsole: true,
spawnCmd: getCmdExePath(),
spawnArgs: ['/d', '/s', '/c', 'code --reuse-window C:\\workspaces\\orca']
})
})
it('quotes Windows paths with spaces in compound commands', () => {
expect(
resolveExternalEditorLaunchSpec('start "" notepad', 'C:\\my notes.md', { platform: 'win32' })
@@ -245,7 +420,7 @@ describe('resolveExternalEditorLaunchSpec', () => {
).toEqual([pathValue])
})
it('does not rewrite compound VS Code commands', () => {
it('does not rewrite compound VS Code commands into WSL remote args', () => {
const pathValue = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project'
expect(

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