diff --git a/.github/workflows/cloud-push-deploy.yml b/.github/workflows/cloud-push-deploy.yml
new file mode 100644
index 00000000000..9290b4ab2ce
--- /dev/null
+++ b/.github/workflows/cloud-push-deploy.yml
@@ -0,0 +1,340 @@
+name: Deploy Push Gateway Production
+
+on:
+ workflow_dispatch:
+ inputs:
+ confirmation:
+ description: Enter DEPLOY_PUSH_GATEWAY to shift production traffic
+ required: true
+ type: string
+
+permissions:
+ contents: read
+ id-token: write
+
+# The gateway applies its own schema at startup against the shared Cloud SQL instance, so a
+# deploy is a connection-budget rollout and belongs in the same serialized group as the relay.
+concurrency:
+ group: production-cloud-sql-rollout
+ cancel-in-progress: false
+
+defaults:
+ run:
+ working-directory: cloud
+
+jobs:
+ deploy:
+ if: >-
+ ${{ vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true' &&
+ github.ref == 'refs/heads/main' }}
+ runs-on: blacksmith-2vcpu-ubuntu-2204
+ environment: production
+ env:
+ GCP_PROJECT_ID: onorca-cloud
+ GCP_REGION: ${{ vars.PRODUCTION_GCP_REGION }}
+ SERVICE_NAME: orca-cloud-push
+ REPOSITORY_ID: orca-cloud
+ IMAGE_NAME: push
+ PUSH_ORIGIN: https://push.onorca.dev
+ PUSH_RUNTIME_SERVICE_ACCOUNT: orca-cloud-push@onorca-cloud.iam.gserviceaccount.com
+ # Scaling the serving revision must already hold, matching push_min_instances and
+ # push_max_instances. Terraform owns both, and the candidate inherits them from the
+ # service, so this deploy never passes a scaling flag: doing so would write a
+ # Terraform-owned field that `lifecycle.ignore_changes` does not cover, and a later
+ # `push_max_instances` raise would then be reverted by every deploy. These two values
+ # are the expected shape, asserted before the candidate is created and again on the
+ # candidate itself, so a deploy that would change the gateway's Cloud SQL draw fails.
+ PUSH_MIN_INSTANCES: 1
+ PUSH_MAX_INSTANCES: 2
+ CONFIRMATION: ${{ inputs.confirmation }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Require the explicit deploy confirmation
+ shell: bash
+ run: |
+ set -euo pipefail
+ test "${CONFIRMATION}" = DEPLOY_PUSH_GATEWAY
+
+ - uses: google-github-actions/auth@v2
+ with:
+ workload_identity_provider: ${{ vars.PRODUCTION_GCP_RELAY_DEPLOY_WORKLOAD_IDENTITY_PROVIDER }}
+ service_account: ${{ vars.PRODUCTION_GCP_RELAY_DEPLOY_SERVICE_ACCOUNT }}
+
+ - uses: google-github-actions/setup-gcloud@v2
+
+ - uses: docker/setup-buildx-action@v3
+
+ - name: Configure Docker auth
+ run: gcloud auth configure-docker "${GCP_REGION}-docker.pkg.dev" --quiet
+
+ # Why: the build runs before the lease. Artifact Registry is not the Cloud SQL instance,
+ # and a multi-minute image build inside the lease blocks every relay deploy and rehome for
+ # its duration. The lease below covers exactly the connection-budget window: deploy, probe,
+ # shift.
+ - name: Build and publish the immutable gateway image
+ shell: bash
+ run: |
+ set -euo pipefail
+ image_tag="${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/${REPOSITORY_ID}/${IMAGE_NAME}:sha-${GITHUB_SHA}"
+ docker build -f apps/push/Dockerfile -t "${image_tag}" .
+ docker push "${image_tag}"
+ digest="$(gcloud artifacts docker images describe "${image_tag}" \
+ --format='value(image_summary.digest)')"
+ [[ "${digest}" =~ ^sha256:[a-f0-9]{64}$ ]]
+ echo "IMAGE=${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/${REPOSITORY_ID}/${IMAGE_NAME}@${digest}" \
+ >> "${GITHUB_ENV}"
+ echo "IMAGE_DIGEST=${digest}" >> "${GITHUB_ENV}"
+
+ # Held across the deploy, not just a separate schema step: the gateway opens its pool and
+ # applies its schema while the new revision starts, so the revision is the schema step.
+ - uses: ./.github/actions/cloud-sql-rollout-lease
+ with:
+ bucket: onorca-cloud-terraform-state
+ object: terraform/state/cloud-sql-rollout/production.lock
+
+ # Why: the candidate inherits the serving revision's scaling. A serving revision that has
+ # drifted below the floor would hand the candidate a cold start on every notification, and
+ # one that has drifted above the ceiling would hand it a larger Cloud SQL draw than the
+ # rollout lease was taken for. Refuse to inherit either rather than latch it.
+ - name: Record the serving revision and require its Terraform-owned scaling
+ shell: bash
+ run: |
+ set -euo pipefail
+ serving="$(gcloud run services describe "${SERVICE_NAME}" \
+ --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" --format=json \
+ | jq -r '[.status.traffic[] | select((.percent // 0) > 0)]
+ | if length == 1 and .[0].percent == 100 then .[0].revisionName else empty end')"
+ test -n "${serving}"
+ floor="$(gcloud run revisions describe "${serving}" \
+ --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" \
+ --format="value(metadata.annotations['autoscaling.knative.dev/minScale'])")"
+ if [[ "${floor:-0}" -lt "${PUSH_MIN_INSTANCES}" ]]; then
+ echo "serving revision ${serving} holds ${floor:-0} minimum instances," \
+ "below ${PUSH_MIN_INSTANCES}; deploying would inherit and latch it." >&2
+ echo "Restore the floor first: gcloud run services update ${SERVICE_NAME}" \
+ "--region ${GCP_REGION} --min-instances=${PUSH_MIN_INSTANCES}" >&2
+ exit 1
+ fi
+ ceiling="$(gcloud run revisions describe "${serving}" \
+ --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" \
+ --format="value(metadata.annotations['autoscaling.knative.dev/maxScale'])")"
+ test "${ceiling}" = "${PUSH_MAX_INSTANCES}"
+ echo "serving revision ${serving} holds ${floor} minimum and ${ceiling} maximum instances"
+ echo "ROLLBACK_REVISION=${serving}" >> "${GITHUB_ENV}"
+
+ # No traffic and a per-revision tag: the candidate boots, applies schema, and is probed on
+ # its own URL while every phone and desktop still reaches the previous revision.
+ - name: Deploy the candidate revision with no traffic
+ shell: bash
+ run: |
+ set -euo pipefail
+ tag="c${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
+ echo "CANDIDATE_TAG=${tag}" >> "${GITHUB_ENV}"
+ echo "CANDIDATE_REVISION=${SERVICE_NAME}-${tag}" >> "${GITHUB_ENV}"
+ gcloud run deploy "${SERVICE_NAME}" \
+ --project "${GCP_PROJECT_ID}" \
+ --region "${GCP_REGION}" \
+ --image "${IMAGE}" \
+ --tag "${tag}" \
+ --revision-suffix "${tag}" \
+ --no-traffic \
+ --quiet
+ candidate="$(gcloud run services describe "${SERVICE_NAME}" \
+ --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" --format=json \
+ | jq -er --arg tag "${tag}" \
+ '[.status.traffic[] | select(.tag == $tag)]
+ | if length == 1 then .[0] else error("tagged candidate is not unique") end')"
+ test "$(jq -r '.revisionName' <<< "${candidate}")" = "${SERVICE_NAME}-${tag}"
+ echo "CANDIDATE_URL=$(jq -r '.url' <<< "${candidate}")" >> "${GITHUB_ENV}"
+
+ # A tagged revision is directly addressable and sits outside the service-wide cap, so the
+ # candidate and the serving revision each draw up to the ceiling during the probe window.
+ # The lease is taken for exactly that doubling; a candidate that inherited a wider ceiling
+ # would exceed it, so the inherited scaling is asserted here too.
+ - name: Require the candidate to serve the exact image and inherited scaling
+ shell: bash
+ run: |
+ set -euo pipefail
+ served="$(gcloud run revisions describe "${CANDIDATE_REVISION}" \
+ --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" \
+ --format='value(spec.containers[0].image)')"
+ test "${served}" = "${IMAGE}"
+ test "${CANDIDATE_REVISION}" != "${ROLLBACK_REVISION}"
+ candidate_ceiling="$(gcloud run revisions describe "${CANDIDATE_REVISION}" \
+ --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" \
+ --format="value(metadata.annotations['autoscaling.knative.dev/maxScale'])")"
+ test "${candidate_ceiling}" = "${PUSH_MAX_INSTANCES}"
+
+ - name: Probe the candidate readiness endpoint
+ shell: bash
+ run: |
+ set -euo pipefail
+ [[ "${CANDIDATE_URL}" =~ ^https://[^/]+$ ]]
+ for attempt in $(seq 1 30); do
+ code="$(curl -sS -o "${RUNNER_TEMP}/push-ready.json" -w '%{http_code}' \
+ --max-time 10 "${CANDIDATE_URL}/ready" || true)"
+ if test "${code}" = 200; then
+ jq -e . < "${RUNNER_TEMP}/push-ready.json" > /dev/null
+ echo "candidate ${CANDIDATE_REVISION} is ready after ${attempt} attempt(s)"
+ exit 0
+ fi
+ echo "attempt ${attempt}: /ready returned ${code}"
+ sleep 5
+ done
+ echo "candidate ${CANDIDATE_REVISION} never reported ready" >&2
+ exit 1
+
+ # Why: a gateway that boots and answers /ready can still be unable to send. This proves the
+ # runtime account's FCM grant end to end without delivering anything: validate_only stops
+ # Google before any push, and the deliberately invalid token means a healthy credential
+ # answers INVALID_ARGUMENT. PERMISSION_DENIED is the failure this step exists to catch.
+ #
+ # Only the four verdicts below are conclusive. A 429, a 5xx, or a transport failure says
+ # nothing about the credential, so it is retried rather than treated as either answer; a
+ # denied credential still fails on the first attempt, without burning the retries.
+ - name: Prove the runtime identity can reach FCM
+ shell: bash
+ run: |
+ set -euo pipefail
+ token="$(gcloud auth print-access-token \
+ --impersonate-service-account "${PUSH_RUNTIME_SERVICE_ACCOUNT}")"
+ test -n "${token}"
+ echo "::add-mask::${token}"
+ body='{"validate_only":true,"message":{"token":"orca-push-deploy-probe-invalid-token","notification":{"title":"Orca","body":"deploy probe"}}}'
+ for attempt in $(seq 1 5); do
+ code="$(curl -sS -o "${RUNNER_TEMP}/push-fcm.json" -w '%{http_code}' --max-time 20 \
+ -X POST "https://fcm.googleapis.com/v1/projects/${GCP_PROJECT_ID}/messages:send" \
+ -H "Authorization: Bearer ${token}" \
+ -H 'Content-Type: application/json' \
+ --data "${body}" || true)"
+ status="$(jq -r '.error.status // empty' < "${RUNNER_TEMP}/push-fcm.json" || true)"
+ echo "attempt ${attempt}: FCM validate-only send returned HTTP ${code} status ${status:-OK}"
+ if test "${status}" = PERMISSION_DENIED || test "${status}" = INVALID_ARGUMENT ||
+ test "${code}" = 401 || test "${code}" = 403; then
+ break
+ fi
+ sleep 5
+ done
+ if test "${status}" = PERMISSION_DENIED || test "${code}" = 401 || test "${code}" = 403; then
+ echo "the push runtime identity cannot send through FCM" >&2
+ exit 1
+ fi
+ test "${status}" = INVALID_ARGUMENT
+
+ - name: Shift all traffic to the verified candidate
+ shell: bash
+ run: |
+ set -euo pipefail
+ echo "TRAFFIC_SHIFT_ATTEMPTED=true" >> "${GITHUB_ENV}"
+ gcloud run services update-traffic "${SERVICE_NAME}" \
+ --project "${GCP_PROJECT_ID}" \
+ --region "${GCP_REGION}" \
+ --to-revisions "${CANDIDATE_REVISION}=100" \
+ --quiet
+ serving="$(gcloud run services describe "${SERVICE_NAME}" \
+ --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" --format=json \
+ | jq -r '[.status.traffic[] | select((.percent // 0) > 0)]
+ | if length == 1 and .[0].percent == 100 then .[0].revisionName else empty end')"
+ test "${serving}" = "${CANDIDATE_REVISION}"
+ echo "TRAFFIC_SHIFTED=true" >> "${GITHUB_ENV}"
+
+ # Why: the summary is written before the origin check, not after it. Once traffic has
+ # moved, the rollback target is the single thing an operator needs, and a summary that only
+ # appeared on success would be missing in exactly the run that needs it.
+ - name: Publish the rollout summary
+ if: ${{ always() && env.CANDIDATE_REVISION != '' && env.ROLLBACK_REVISION != '' }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ {
+ echo '### Push gateway rollout'
+ echo
+ echo "Revision: \`${CANDIDATE_REVISION}\`"
+ echo
+ echo "Image: \`${IMAGE_DIGEST}\`"
+ echo
+ echo "Rollback: \`gcloud run services update-traffic ${SERVICE_NAME}" \
+ "--region ${GCP_REGION} --to-revisions ${ROLLBACK_REVISION}=100\`"
+ } >> "${GITHUB_STEP_SUMMARY}"
+
+ - name: Verify the public origin after the shift
+ shell: bash
+ run: |
+ set -euo pipefail
+ for attempt in $(seq 1 30); do
+ code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 \
+ "${PUSH_ORIGIN}/ready" || true)"
+ if test "${code}" = 200; then
+ echo "${PUSH_ORIGIN} is ready after ${attempt} attempt(s)"
+ exit 0
+ fi
+ echo "attempt ${attempt}: ${PUSH_ORIGIN}/ready returned ${code}"
+ sleep 5
+ done
+ echo "${PUSH_ORIGIN} never reported ready after the shift" >&2
+ exit 1
+
+ # Why: everything after the shift runs with production on the candidate. A failure there
+ # is not a failure to deploy, it is a live gateway that has to go back, so the traffic move
+ # is undone here rather than left to whoever reads the run.
+ - name: Roll traffic back to the previous revision
+ if: ${{ (failure() || cancelled()) && env.TRAFFIC_SHIFT_ATTEMPTED == 'true' }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ test -n "${ROLLBACK_REVISION:-}"
+ gcloud run services update-traffic "${SERVICE_NAME}" \
+ --project "${GCP_PROJECT_ID}" \
+ --region "${GCP_REGION}" \
+ --to-revisions "${ROLLBACK_REVISION}=100" \
+ --quiet
+ serving="$(gcloud run services describe "${SERVICE_NAME}" \
+ --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" --format=json \
+ | jq -r '[.status.traffic[] | select((.percent // 0) > 0)]
+ | if length == 1 and .[0].percent == 100 then .[0].revisionName else empty end')"
+ test "${serving}" = "${ROLLBACK_REVISION}"
+ echo "TRAFFIC_ROLLED_BACK=true" >> "${GITHUB_ENV}"
+ {
+ echo
+ echo '### Push gateway rolled back'
+ echo
+ echo "Traffic returned to \`${ROLLBACK_REVISION}\`; the candidate" \
+ "\`${CANDIDATE_REVISION}\` no longer serves."
+ } >> "${GITHUB_STEP_SUMMARY}"
+
+ # Why: a candidate that never took traffic is a revision holding a warm floor and a Cloud
+ # SQL pool for nothing. Its tag comes off first, because Cloud Run refuses to delete a
+ # revision a traffic target still names, and clearing CANDIDATE_TAG makes the always() tag
+ # step below a no-op rather than a second failure.
+ - name: Delete the rejected candidate revision
+ if: ${{ (failure() || cancelled()) && (env.TRAFFIC_SHIFT_ATTEMPTED != 'true' || env.TRAFFIC_ROLLED_BACK == 'true') }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ test -n "${CANDIDATE_REVISION:-}" || exit 0
+ if test -n "${CANDIDATE_TAG:-}"; then
+ gcloud run services update-traffic "${SERVICE_NAME}" \
+ --project "${GCP_PROJECT_ID}" \
+ --region "${GCP_REGION}" \
+ --remove-tags "${CANDIDATE_TAG}" \
+ --quiet
+ echo "CANDIDATE_TAG=" >> "${GITHUB_ENV}"
+ fi
+ gcloud run revisions delete "${CANDIDATE_REVISION}" \
+ --project "${GCP_PROJECT_ID}" \
+ --region "${GCP_REGION}" \
+ --quiet
+ echo "deleted the candidate revision ${CANDIDATE_REVISION}"
+
+ - name: Drop the candidate traffic tag
+ if: always()
+ shell: bash
+ run: |
+ set -euo pipefail
+ test -n "${CANDIDATE_TAG:-}" || exit 0
+ gcloud run services update-traffic "${SERVICE_NAME}" \
+ --project "${GCP_PROJECT_ID}" \
+ --region "${GCP_REGION}" \
+ --remove-tags "${CANDIDATE_TAG}" \
+ --quiet
diff --git a/.github/workflows/cloud-verify.yml b/.github/workflows/cloud-verify.yml
index e2ba9407ac4..5e24cae76cc 100644
--- a/.github/workflows/cloud-verify.yml
+++ b/.github/workflows/cloud-verify.yml
@@ -90,6 +90,7 @@ jobs:
--health-timeout 5s
--health-retries 10
env:
+ ORCA_PUSH_TEST_DATABASE_URL: postgres://relay_test:relay_test@127.0.0.1:5432/orca_relay_test
ORCA_RELAY_TEST_POSTGRES_URL: postgres://relay_test:relay_test@127.0.0.1:5432/orca_relay_test
steps:
- uses: actions/checkout@v4
diff --git a/.github/workflows/mobile-android-release.yml b/.github/workflows/mobile-android-release.yml
index 35e900dc31c..17100c788b8 100644
--- a/.github/workflows/mobile-android-release.yml
+++ b/.github/workflows/mobile-android-release.yml
@@ -104,11 +104,47 @@ jobs:
--clobber \
android/app/build/outputs/apk/release/*.apk
else
+ # Why: release tags live on side branches, so GitHub's automatic
+ # previous-tag detection reaches back several releases; that body
+ # already exceeds the 125000-character API limit and grows each
+ # release. Pin the comparison base and cap the size.
+ notes_file="$RUNNER_TEMP/android-release-notes.md"
+ previous_tag="$(
+ gh release list --repo "$GITHUB_REPOSITORY" --limit 200 --json tagName --jq '.[].tagName' \
+ | grep '^mobile-android-v' | grep -Fxv "$tag" | sort -V | tail -1 || true
+ )"
+
+ if [ -n "$previous_tag" ]; then
+ # Why: gh writes the JSON error body to stdout on an HTTP error, so a
+ # non-empty file is not proof of success — gate on exit status.
+ if ! gh api "repos/$GITHUB_REPOSITORY/releases/generate-notes" -X POST \
+ -f tag_name="$tag" \
+ -f target_commitish="$GITHUB_SHA" \
+ -f previous_tag_name="$previous_tag" \
+ --jq .body > "$notes_file"; then
+ : > "$notes_file"
+ fi
+ fi
+ if [ ! -s "$notes_file" ]; then
+ printf 'Orca Mobile Android %s\n' "$tag" > "$notes_file"
+ fi
+ # Why: reuse the desktop release path's character-safe truncation so a
+ # multi-byte character cannot be split at the cap.
+ NOTES_FILE="$notes_file" \
+ NOTES_MODULE="$GITHUB_WORKSPACE/config/scripts/create-draft-release.mjs" \
+ node --input-type=module -e '
+ const { readFileSync, writeFileSync } = await import("node:fs")
+ const { pathToFileURL } = await import("node:url")
+ const { truncateReleaseBody } = await import(pathToFileURL(process.env.NOTES_MODULE).href)
+ const file = process.env.NOTES_FILE
+ writeFileSync(file, truncateReleaseBody(readFileSync(file, "utf8")))
+ '
+
gh release create "$tag" \
--repo "$GITHUB_REPOSITORY" \
--title "Orca Mobile Android $tag" \
--prerelease \
--latest=false \
- --generate-notes \
+ --notes-file "$notes_file" \
android/app/build/outputs/apk/release/*.apk
fi
diff --git a/.github/workflows/mobile-ios-release.yml b/.github/workflows/mobile-ios-release.yml
index 934b3f694a3..27372260c01 100644
--- a/.github/workflows/mobile-ios-release.yml
+++ b/.github/workflows/mobile-ios-release.yml
@@ -94,6 +94,13 @@ jobs:
run: node -e 'const fs = require("node:fs"); const { expo } = require("./app.json"); fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${expo.version}\nbuild_number=${expo.ios.buildNumber}\n`)'
- name: Expo prebuild
+ # Why the env var: app.config.js derives the expo-notifications plugin's
+ # `mode` from it, which is what writes `aps-environment: production` into the
+ # entitlements. push-token.ts reports a production APNs environment for every
+ # non-__DEV__ build, so a development entitlement here would leave TestFlight
+ # and App Store builds registered against a sandbox they never receive from.
+ env:
+ ORCA_IOS_APS_ENVIRONMENT: production
run: npx expo prebuild --platform ios --no-install
- name: Install CocoaPods
diff --git a/.github/workflows/packaged-browser-e2e.yml b/.github/workflows/packaged-browser-e2e.yml
new file mode 100644
index 00000000000..2a23ac58988
--- /dev/null
+++ b/.github/workflows/packaged-browser-e2e.yml
@@ -0,0 +1,74 @@
+name: Packaged browser compatibility
+on:
+ workflow_dispatch:
+ inputs:
+ ref:
+ description: Commit SHA or ref to validate (defaults to the selected revision)
+ type: string
+ required: false
+ schedule:
+ - cron: '20 8 * * 1'
+ workflow_call:
+ inputs:
+ ref:
+ type: string
+ required: false
+permissions:
+ contents: read
+jobs:
+ compatibility:
+ runs-on: ubuntu-latest
+ timeout-minutes: 25
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs.ref || github.sha }}
+ persist-credentials: false
+ - name: Install headless tools
+ run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 ripgrep xvfb zsh openbox x11-utils
+ - uses: ./.github/actions/install-node-dependencies
+ with:
+ native-runtime: electron
+ - name: Download pinned old release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh release download v1.4.188 --repo stablyai/orca --pattern orca-ide_1.4.188_amd64.deb --dir "$RUNNER_TEMP/old-orca"
+ python3 - <<'PYVERIFY'
+ import base64,hashlib,os,pathlib,subprocess
+ root=pathlib.Path(os.environ['RUNNER_TEMP'])/'old-orca'
+ package=root/'orca-ide_1.4.188_amd64.deb'
+ expected='uGONFUDfinYggxcT9ac72wnnlofLQaqasDDeP0HWOSqarBwTi1Ax3khmzKUY3vUnvuYOpSCEmsH4InzLZ2vg6g=='
+ assert base64.b64encode(hashlib.sha512(package.read_bytes()).digest()).decode()==expected
+ extracted=root/'extracted'
+ subprocess.run(['dpkg-deb','-x',str(package),str(extracted)],check=True)
+ executable=extracted/'opt'/'Orca'/'orca-ide'
+ assert executable.is_file() and os.access(executable,os.X_OK)
+ with open(os.environ['GITHUB_ENV'],'a') as env: env.write('ORCA_CROSS_VERSION_PACKAGED_EXECUTABLE='+str(executable)+'\n')
+ print('Verified old package:',executable)
+ PYVERIFY
+ - name: Build current Electron app
+ env:
+ VITE_EXPOSE_STORE: 'true'
+ run: |
+ pnpm run build:relay
+ pnpm exec electron-vite build --mode e2e
+ pnpm run build:web-from-renderer
+ - name: Run both mixed-version directions
+ env:
+ PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/packaged-browser-results.json
+ run: >-
+ xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh
+ env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1
+ pnpm exec playwright test --config tests/playwright.config.ts
+ tests/e2e/packaged-mixed-version-browser-placement.spec.ts
+ --project=electron-headless --workers=1 --retries=0 --repeat-each=3 --reporter=list,json
+ - name: Require all six compatibility executions
+ if: always()
+ run: node config/scripts/verify-packaged-browser-participation.mjs test-results/packaged-browser-results.json
+ - uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: packaged-mixed-version-audit
+ path: test-results/
+ retention-days: 3
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 1fd141ee4a0..9279f35b39f 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -858,6 +858,7 @@ jobs:
src/main/windows/windows-pty-job.win32.test.ts
src/main/windows/windows-host-job.win32.test.ts
src/main/windows/windows-process-tree-command-line-patch.test.ts
+ src/main/windows/windows-process-table-native-addon.win32.test.ts
src/main/windows-live-tree-kill.win32.test.ts
src/main/wsl/wsl-runner.test.ts
src/main/wsl/wsl-guest-environment.test.ts
diff --git a/.github/workflows/terminal-ime-e2e.yml b/.github/workflows/terminal-ime-e2e.yml
index 1ab905d8783..bd6be26bd27 100644
--- a/.github/workflows/terminal-ime-e2e.yml
+++ b/.github/workflows/terminal-ime-e2e.yml
@@ -70,3 +70,40 @@ jobs:
path: test-results/
retention-days: 7
if-no-files-found: ignore
+
+ linux-wayland:
+ name: Linux Wayland Hangul terminating digit
+ runs-on: ubuntu-22.04
+ timeout-minutes: 25
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+ - name: Install native build, nested compositor and IME tools
+ run: >-
+ sudo apt-get update && sudo apt-get install -y
+ build-essential python3 fonts-noto-cjk dbus-x11 dconf-gsettings-backend
+ ibus ibus-hangul gnome-shell gnome-settings-daemon libglib2.0-bin
+ xdotool xvfb x11-utils imagemagick
+ - uses: ./.github/actions/install-node-dependencies
+ with:
+ native-runtime: electron
+ - name: Build Electron app for E2E
+ env:
+ VITE_EXPOSE_STORE: 'true'
+ run: |
+ pnpm run build:relay
+ pnpm exec electron-vite build --mode e2e
+ pnpm run build:web-from-renderer
+ - name: Run native Wayland Hangul terminating digit
+ env:
+ SKIP_BUILD: '1'
+ run: node config/scripts/run-terminal-ibus-hangul-e2e.mjs --nested-wayland
+ - name: Upload Wayland terminal IME evidence
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: terminal-wayland-ime-evidence
+ path: test-results/
+ retention-days: 7
+ if-no-files-found: error
diff --git a/.gitignore b/.gitignore
index 6722fc5ae54..37519cf04f5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -107,6 +107,7 @@ docs/**
!docs/reference/headless-linux-server.md
!docs/reference/ime-regression-checklist.md
!docs/reference/linux-glibc-compatibility.md
+!docs/reference/mobile-push-contract.md
!docs/reference/macos-press-and-hold.md
!docs/reference/orcad-operations.md
!docs/reference/relay-grace-time-reconfiguration.md
diff --git a/README.md b/README.md
index 2ae59035da8..7e3540c80f1 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
-[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.48](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
-- **Android:** [Download APK 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
+- **Android:** [Download APK 0.0.48](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
---
diff --git a/cloud/README.md b/cloud/README.md
index 8ffcd9fa6b3..a2171700bb1 100644
--- a/cloud/README.md
+++ b/cloud/README.md
@@ -24,6 +24,32 @@ the repository's root [MIT license](../LICENSE).
- `apps/relay-ops`: the relay operations console and the incident monitor
behind `pnpm ops:relay`, `pnpm incident:relay`, and
`pnpm incident:relay-preflight`.
+- `apps/push` and `packages/push-contract`: the mobile push gateway that holds
+ the APNs key and sends to phones through APNs and FCM, and its wire contract.
+ It is deployed and operated from here but is not part of the relay data path;
+ see [docs/push-gateway.md](docs/push-gateway.md).
+
+## Mobile push gateway
+
+`apps/push` is a separate Cloud Run service from the relay. Phones never hold an
+Orca credential for it: the desktop host authenticates with the same X25519
+key it uses for the relay, answering an encrypted challenge to mint a 24 hour
+session, then registers each paired phone's native push token and asks the
+gateway to push. The gateway coalesces a burst per registration into one
+notification, enforces per-host and per-registration quotas, and retires a
+registration as soon as Apple or Google reports the token unregistered.
+
+Storage follows the relay pattern: PostgreSQL in production, SQLite for tests
+and local development. Configure it with `ORCA_PUSH_PUBLIC_URL`,
+`ORCA_PUSH_DATABASE_URL`, the three APNs variables (`ORCA_PUSH_APNS_KEY`,
+`ORCA_PUSH_APNS_KEY_ID`, `ORCA_PUSH_APPLE_TEAM_ID`, all three or none), and
+optionally `ORCA_PUSH_APNS_TOPIC`, `ORCA_PUSH_FCM_PROJECT_ID`, and
+`ORCA_PUSH_COALESCE_MS`. The FCM credential comes from the runtime service
+account, so no key material is configured for Android. The full contract lives
+in `docs/reference/mobile-push-contract.md` at the repository root.
+
+Logging is aggregate counters only. Tokens, notification titles, notification
+bodies, and full host fingerprints never reach a log line.
## Infrastructure and operations
@@ -38,16 +64,18 @@ the repository's root [MIT license](../LICENSE).
- `dev/contracts` and `dev/fixtures`: the checked-in data those contract tests
read, including the Terraform root partition.
- `docs/`: the relay runbooks, capacity-testing guide, incident-monitor
- reference, and the workflow variable reference in `docs/relay-workflows.md`.
+ reference, the workflow variable reference in `docs/relay-workflows.md`, and
+ the push gateway runbook in `docs/push-gateway.md`.
## Workflows
-The 24 `.github/workflows/cloud-*.yml` workflows are the relay's deploy and
-operate surface: publish and deploy the director, roll GCE cell capacity,
-operate Asia admission and regional rehoming, prove staging capacity, monitor
-production, and power staging up and down. `.github/actions/cloud-sql-rollout-lease`
-is the compare-and-swap lease that serializes every rollout against the shared
-Cloud SQL instance.
+The 25 `.github/workflows/cloud-*.yml` workflows are the deploy and operate
+surface: publish and deploy the director, roll GCE cell capacity, operate Asia
+admission and regional rehoming, prove staging capacity, monitor production,
+power staging up and down, and deploy the mobile push gateway.
+`.github/actions/cloud-sql-rollout-lease` is the compare-and-swap lease that
+serializes every rollout against the shared Cloud SQL instance, the push
+gateway deploy included.
Every one of them is inert. Each top-level job is gated on
`vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true'`, a repository variable that is
diff --git a/cloud/apps/push/Dockerfile b/cloud/apps/push/Dockerfile
new file mode 100644
index 00000000000..efdc85fc404
--- /dev/null
+++ b/cloud/apps/push/Dockerfile
@@ -0,0 +1,29 @@
+FROM node:24-alpine AS build
+WORKDIR /app
+RUN corepack enable
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
+COPY packages/push-contract/package.json packages/push-contract/package.json
+COPY packages/postgres-schema/package.json packages/postgres-schema/package.json
+COPY apps/push/package.json apps/push/package.json
+RUN pnpm install --frozen-lockfile
+COPY packages/push-contract packages/push-contract
+COPY apps/push apps/push
+COPY packages/postgres-schema packages/postgres-schema
+RUN pnpm --filter @orca-cloud/postgres-schema build && pnpm --filter @orca-cloud/push-contract build && pnpm --filter @orca-cloud/push build
+
+FROM node:24-alpine AS runtime
+ENV NODE_ENV=production
+ENV PORT=8080
+WORKDIR /app
+RUN corepack enable
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
+COPY packages/push-contract/package.json packages/push-contract/package.json
+COPY packages/postgres-schema/package.json packages/postgres-schema/package.json
+COPY apps/push/package.json apps/push/package.json
+COPY --from=build /app/packages/push-contract/dist packages/push-contract/dist
+COPY --from=build /app/packages/postgres-schema/dist packages/postgres-schema/dist
+COPY --from=build /app/apps/push/dist apps/push/dist
+RUN pnpm install --prod --frozen-lockfile --filter @orca-cloud/push...
+USER node
+EXPOSE 8080
+CMD ["node", "apps/push/dist/index.js"]
diff --git a/cloud/apps/push/package.json b/cloud/apps/push/package.json
new file mode 100644
index 00000000000..d84d0af8b25
--- /dev/null
+++ b/cloud/apps/push/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "@orca-cloud/push",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "main": "dist/index.js",
+ "scripts": {
+ "build": "pnpm clean && tsc -p tsconfig.build.json",
+ "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
+ "dev": "tsx watch src/index.ts",
+ "lint": "tsc -p tsconfig.json --noEmit",
+ "pretest": "pnpm --filter @orca-cloud/postgres-schema build && pnpm --filter @orca-cloud/push-contract build",
+ "start": "node dist/index.js",
+ "test": "vitest run",
+ "typecheck": "tsc -p tsconfig.json --noEmit"
+ },
+ "dependencies": {
+ "@hono/node-server": "^1.19.14",
+ "@orca-cloud/postgres-schema": "workspace:*",
+ "@orca-cloud/push-contract": "workspace:*",
+ "google-auth-library": "^10.5.0",
+ "hono": "^4.12.27",
+ "pg": "^8.22.0",
+ "tweetnacl": "^1.0.3",
+ "zod": "^3.25.76"
+ },
+ "devDependencies": {
+ "@types/node": "^24.10.0",
+ "@types/pg": "^8.20.0",
+ "tsx": "^4.21.0",
+ "typescript": "^5.9.3",
+ "vitest": "^4.0.8"
+ }
+}
diff --git a/cloud/apps/push/src/apns-authentication-token.ts b/cloud/apps/push/src/apns-authentication-token.ts
new file mode 100644
index 00000000000..34def16e86e
--- /dev/null
+++ b/cloud/apps/push/src/apns-authentication-token.ts
@@ -0,0 +1,42 @@
+import { createPrivateKey, type KeyObject, sign } from 'node:crypto'
+import type { ApnsCredentials } from './config.js'
+
+// Apple rejects a provider token older than an hour and throttles reissue
+// under about 20 minutes, so 50 minutes is the safe rotation point.
+export const APNS_TOKEN_ROTATION_MS = 50 * 60 * 1000
+
+function base64UrlJson(value: Record): string {
+ return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url')
+}
+
+export class ApnsAuthenticationToken {
+ private readonly privateKey: KeyObject
+ private cached: { token: string; issuedAtMs: number } | null = null
+
+ constructor(
+ private readonly credentials: ApnsCredentials,
+ private readonly now: () => number = Date.now,
+ private readonly rotationMs: number = APNS_TOKEN_ROTATION_MS
+ ) {
+ this.privateKey = createPrivateKey(credentials.keyPem)
+ }
+
+ value(): string {
+ const nowMs = this.now()
+ if (this.cached && nowMs - this.cached.issuedAtMs < this.rotationMs) return this.cached.token
+ const header = base64UrlJson({ alg: 'ES256', kid: this.credentials.keyId })
+ const payload = base64UrlJson({
+ iss: this.credentials.teamId,
+ iat: Math.floor(nowMs / 1000)
+ })
+ const signingInput = `${header}.${payload}`
+ // ES256 requires the raw r||s pair; Node emits DER unless asked otherwise.
+ const signature = sign('sha256', Buffer.from(signingInput, 'utf8'), {
+ key: this.privateKey,
+ dsaEncoding: 'ieee-p1363'
+ }).toString('base64url')
+ const token = `${signingInput}.${signature}`
+ this.cached = { token, issuedAtMs: nowMs }
+ return token
+ }
+}
diff --git a/cloud/apps/push/src/apns-client.test.ts b/cloud/apps/push/src/apns-client.test.ts
new file mode 100644
index 00000000000..c0f312e46e6
--- /dev/null
+++ b/cloud/apps/push/src/apns-client.test.ts
@@ -0,0 +1,174 @@
+import { generateKeyPairSync } from 'node:crypto'
+import { describe, expect, it } from 'vitest'
+import { ApnsAuthenticationToken, APNS_TOKEN_ROTATION_MS } from './apns-authentication-token.js'
+import { ApnsClient } from './apns-client.js'
+import type { ApnsRequest, ApnsResponse } from './apns-http2-transport.js'
+import type { ApnsCredentials } from './config.js'
+import { buildPushDelivery } from './push-delivery-message.js'
+
+const HOST = 'abcdefghijklmnop'
+
+function credentials(): ApnsCredentials {
+ const { privateKey } = generateKeyPairSync('ec', {
+ namedCurve: 'P-256',
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+ publicKeyEncoding: { type: 'spki', format: 'pem' }
+ })
+ return { keyPem: privateKey, keyId: 'ABCDE12345', teamId: 'TEAM123456' }
+}
+
+function delivery(coalescedCount = 1) {
+ return buildPushDelivery({
+ registrationId: 'reg-1',
+ hostFingerprint: HOST,
+ notification: {
+ notificationId: 'note-1',
+ notificationSeq: 7,
+ notificationEpoch: 'epoch-1',
+ source: 'agent-task-complete',
+ agentState: 'needs-input',
+ title: 'Agent needs input',
+ body: 'Waiting on your answer',
+ worktreeId: 'wt-1'
+ },
+ title: 'Agent needs input',
+ body: 'Waiting on your answer',
+ coalescedCount
+ })
+}
+
+function fakeTransport(response: ApnsResponse) {
+ const requests: ApnsRequest[] = []
+ return {
+ requests,
+ transport: async (request: ApnsRequest): Promise => {
+ requests.push(request)
+ return response
+ }
+ }
+}
+
+describe('apns authentication token', () => {
+ it('signs an ES256 provider token and caches it until the rotation point', () => {
+ let clock = 1_700_000_000_000
+ const authentication = new ApnsAuthenticationToken(credentials(), () => clock)
+ const first = authentication.value()
+ const [header, payload, signature] = first.split('.')
+ expect(JSON.parse(Buffer.from(header!, 'base64url').toString('utf8'))).toEqual({
+ alg: 'ES256',
+ kid: 'ABCDE12345'
+ })
+ expect(JSON.parse(Buffer.from(payload!, 'base64url').toString('utf8'))).toEqual({
+ iss: 'TEAM123456',
+ iat: Math.floor(clock / 1000)
+ })
+ expect(Buffer.from(signature!, 'base64url').byteLength).toBe(64)
+
+ clock += APNS_TOKEN_ROTATION_MS - 1
+ expect(authentication.value()).toBe(first)
+ clock += 1
+ expect(authentication.value()).not.toBe(first)
+ })
+})
+
+describe('apns client', () => {
+ it('sends the specified headers, path, and alert body', async () => {
+ const clock = 1_700_000_000_000
+ const fake = fakeTransport({ status: 200, body: '' })
+ const client = new ApnsClient({
+ topic: 'com.stably.orca.mobile',
+ credentials: credentials(),
+ transport: fake.transport,
+ now: () => clock
+ })
+ await expect(
+ client.send(delivery(), { token: 'a'.repeat(64), apnsEnvironment: 'production' })
+ ).resolves.toEqual({ status: 'sent' })
+ const request = fake.requests[0]!
+ expect(request.host).toBe('api.push.apple.com')
+ expect(request.path).toBe(`/3/device/${'a'.repeat(64)}`)
+ expect(request.headers).toMatchObject({
+ 'apns-topic': 'com.stably.orca.mobile',
+ 'apns-push-type': 'alert',
+ 'apns-priority': '10',
+ 'apns-expiration': String(Math.floor(clock / 1000) + 4 * 60 * 60),
+ 'apns-collapse-id': 'note-1'
+ })
+ expect(request.headers.authorization).toMatch(/^bearer /)
+ expect(JSON.parse(request.body)).toEqual({
+ aps: {
+ alert: { title: 'Agent needs input', body: 'Waiting on your answer' },
+ sound: 'default',
+ 'thread-id': HOST
+ },
+ orca: {
+ hostFingerprint: HOST,
+ worktreeId: 'wt-1',
+ notificationId: 'note-1',
+ notificationSeq: 7,
+ notificationEpoch: 'epoch-1',
+ source: 'agent-task-complete',
+ agentState: 'needs-input',
+ coalescedCount: 1
+ }
+ })
+ })
+
+ it('targets the sandbox host and the host collapse id for a summary', async () => {
+ const fake = fakeTransport({ status: 200, body: '' })
+ const client = new ApnsClient({
+ topic: 'com.stably.orca.mobile',
+ credentials: credentials(),
+ transport: fake.transport
+ })
+ await client.send(delivery(3), { token: 'b'.repeat(64), apnsEnvironment: 'sandbox' })
+ expect(fake.requests[0]?.host).toBe('api.sandbox.push.apple.com')
+ expect(fake.requests[0]?.headers['apns-collapse-id']).toBe(`host:${HOST}`)
+ })
+
+ it.each([
+ [410, 'Unregistered'],
+ [400, 'BadDeviceToken'],
+ [400, 'Unregistered'],
+ [400, 'DeviceTokenNotForTopic']
+ ])('classifies %i %s as a dead token', async (status, reason) => {
+ const fake = fakeTransport({ status, body: JSON.stringify({ reason }) })
+ const client = new ApnsClient({
+ topic: 'com.stably.orca.mobile',
+ credentials: credentials(),
+ transport: fake.transport
+ })
+ await expect(
+ client.send(delivery(), { token: 'a'.repeat(64), apnsEnvironment: 'production' })
+ ).resolves.toEqual({ status: 'dead', reason })
+ })
+
+ it.each([
+ [400, 'PayloadTooLarge'],
+ [429, 'TooManyRequests'],
+ [500, 'InternalServerError']
+ ])('treats %i %s with the appropriate retry policy', async (status, reason) => {
+ const fake = fakeTransport({ status, body: JSON.stringify({ reason }) })
+ const client = new ApnsClient({
+ topic: 'com.stably.orca.mobile',
+ credentials: credentials(),
+ transport: fake.transport
+ })
+ await expect(
+ client.send(delivery(), { token: 'a'.repeat(64), apnsEnvironment: 'production' })
+ ).resolves.toEqual({ status: 'error', reason, retryable: status === 429 || status >= 500 })
+ })
+
+ it('reports a transport failure as an error rather than throwing', async () => {
+ const client = new ApnsClient({
+ topic: 'com.stably.orca.mobile',
+ credentials: credentials(),
+ transport: async () => {
+ throw new Error('socket hang up')
+ }
+ })
+ await expect(
+ client.send(delivery(), { token: 'a'.repeat(64), apnsEnvironment: 'production' })
+ ).resolves.toEqual({ status: 'error', reason: 'Error', retryable: true })
+ })
+})
diff --git a/cloud/apps/push/src/apns-client.ts b/cloud/apps/push/src/apns-client.ts
new file mode 100644
index 00000000000..767b96e83df
--- /dev/null
+++ b/cloud/apps/push/src/apns-client.ts
@@ -0,0 +1,91 @@
+import { PUSH_LIMITS, type ApnsEnvironment } from '@orca-cloud/push-contract'
+import { ApnsAuthenticationToken } from './apns-authentication-token.js'
+import type { ApnsTransport } from './apns-http2-transport.js'
+import type { ApnsCredentials } from './config.js'
+import type { PushDelivery } from './push-delivery-message.js'
+import type { PushProviderOutcome } from './push-provider-outcome.js'
+
+const APNS_HOSTS: Record = {
+ production: 'api.push.apple.com',
+ sandbox: 'api.sandbox.push.apple.com'
+}
+
+const DEAD_TOKEN_REASONS = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic'])
+
+export type ApnsClientOptions = {
+ topic: string
+ credentials: ApnsCredentials
+ transport: ApnsTransport
+ now?: () => number
+}
+
+function readReason(body: string): string {
+ try {
+ const parsed = JSON.parse(body) as { reason?: unknown }
+ return typeof parsed.reason === 'string' ? parsed.reason : 'unknown'
+ } catch {
+ return 'unparseable'
+ }
+}
+
+export function apnsBody(delivery: PushDelivery): string {
+ return JSON.stringify({
+ aps: {
+ alert: { title: delivery.title, body: delivery.body },
+ ...(delivery.sound === false ? {} : { sound: 'default' }),
+ 'thread-id': delivery.hostFingerprint
+ },
+ orca: delivery.orca
+ })
+}
+
+export class ApnsClient {
+ private readonly authentication: ApnsAuthenticationToken
+ private readonly now: () => number
+
+ constructor(private readonly options: ApnsClientOptions) {
+ this.now = options.now ?? Date.now
+ this.authentication = new ApnsAuthenticationToken(options.credentials, this.now)
+ }
+
+ async send(
+ delivery: PushDelivery,
+ device: { token: string; apnsEnvironment: ApnsEnvironment }
+ ): Promise {
+ const expiration = Math.floor(this.now() / 1000) + PUSH_LIMITS.notificationTtlSeconds
+ let response
+ try {
+ response = await this.options.transport({
+ host: APNS_HOSTS[device.apnsEnvironment],
+ path: `/3/device/${device.token}`,
+ headers: {
+ authorization: `bearer ${this.authentication.value()}`,
+ 'apns-topic': this.options.topic,
+ 'apns-push-type': 'alert',
+ 'apns-priority': '10',
+ 'apns-expiration': String(expiration),
+ 'apns-collapse-id': delivery.collapseId
+ },
+ body: apnsBody(delivery)
+ })
+ } catch (error) {
+ return {
+ status: 'error',
+ reason: error instanceof Error ? error.name : 'transport_failed',
+ retryable: true
+ }
+ }
+ if (response.status === 200) return { status: 'sent' }
+ const reason = readReason(response.body)
+ if (response.status === 410) return { status: 'dead', reason }
+ if (response.status === 400 && DEAD_TOKEN_REASONS.has(reason)) {
+ return { status: 'dead', reason }
+ }
+ return {
+ status: 'error',
+ reason,
+ retryable: response.status === 429 || response.status >= 500,
+ ...(response.retryAfterMs === undefined ? {} : { retryAfterMs: response.retryAfterMs })
+ }
+ }
+}
diff --git a/cloud/apps/push/src/apns-http2-transport.ts b/cloud/apps/push/src/apns-http2-transport.ts
new file mode 100644
index 00000000000..167b4d14e38
--- /dev/null
+++ b/cloud/apps/push/src/apns-http2-transport.ts
@@ -0,0 +1,50 @@
+import { connect, constants, type ClientHttp2Session } from 'node:http2'
+import { readApnsStreamResponse, type ApnsResponse } from './apns-stream-response.js'
+
+export type ApnsRequest = {
+ host: string
+ path: string
+ headers: Record
+ body: string
+}
+
+export type { ApnsResponse }
+export type ApnsTransport = (request: ApnsRequest) => Promise
+
+// APNs requires HTTP/2 and rewards a long-lived session per host, so sessions
+// are cached and only dropped when the socket itself goes away.
+export function createApnsHttp2Transport(): ApnsTransport & { close(): void } {
+ const sessions = new Map()
+
+ const sessionFor = (host: string): ClientHttp2Session => {
+ const existing = sessions.get(host)
+ if (existing && !existing.closed && !existing.destroyed) return existing
+ const session = connect(`https://${host}`)
+ const forget = (): void => {
+ if (sessions.get(host) === session) sessions.delete(host)
+ }
+ session.on('error', forget)
+ session.on('close', forget)
+ sessions.set(host, session)
+ return session
+ }
+
+ const transport = async (request: ApnsRequest): Promise => {
+ const stream = sessionFor(request.host).request({
+ ...request.headers,
+ [constants.HTTP2_HEADER_METHOD]: 'POST',
+ [constants.HTTP2_HEADER_PATH]: request.path,
+ [constants.HTTP2_HEADER_AUTHORITY]: request.host,
+ 'content-type': 'application/json',
+ 'content-length': String(Buffer.byteLength(request.body))
+ })
+ return await readApnsStreamResponse(stream, request.body)
+ }
+
+ return Object.assign(transport, {
+ close(): void {
+ for (const session of sessions.values()) session.close()
+ sessions.clear()
+ }
+ })
+}
diff --git a/cloud/apps/push/src/apns-session-replacement.test.ts b/cloud/apps/push/src/apns-session-replacement.test.ts
new file mode 100644
index 00000000000..2678732ca94
--- /dev/null
+++ b/cloud/apps/push/src/apns-session-replacement.test.ts
@@ -0,0 +1,45 @@
+import { EventEmitter } from 'node:events'
+import { expect, it, vi } from 'vitest'
+const mocks = vi.hoisted(() => ({
+ connect: vi.fn(),
+ read: vi.fn(async () => ({ status: 200, body: '' }))
+}))
+vi.mock('node:http2', async (original) => ({
+ ...(await original()),
+ connect: mocks.connect
+}))
+vi.mock('./apns-stream-response.js', () => ({ readApnsStreamResponse: mocks.read }))
+import { createApnsHttp2Transport } from './apns-http2-transport.js'
+
+it('keeps the replacement cached when the draining session closes later', async () => {
+ const sessions: Array<
+ EventEmitter & {
+ closed: boolean
+ destroyed: boolean
+ request: ReturnType
+ close: ReturnType
+ }
+ > = []
+ mocks.connect.mockImplementation(() => {
+ const session = Object.assign(new EventEmitter(), {
+ closed: false,
+ destroyed: false,
+ request: vi.fn(() => ({})),
+ close: vi.fn()
+ })
+ sessions.push(session)
+ return session
+ })
+ const transport = createApnsHttp2Transport()
+ const request = { host: 'api.push.apple.com', path: '/synthetic', headers: {}, body: '{}' }
+ await transport(request)
+ sessions[0]!.closed = true
+ await transport(request)
+ sessions[0]!.emit('close')
+ sessions[0]!.emit('error', new Error('old-session'))
+ await transport(request)
+ expect(sessions).toHaveLength(2)
+ expect(sessions[1]!.request).toHaveBeenCalledTimes(2)
+ transport.close()
+ expect(sessions[1]!.close).toHaveBeenCalledOnce()
+})
diff --git a/cloud/apps/push/src/apns-stream-response.test.ts b/cloud/apps/push/src/apns-stream-response.test.ts
new file mode 100644
index 00000000000..c87b9031ca1
--- /dev/null
+++ b/cloud/apps/push/src/apns-stream-response.test.ts
@@ -0,0 +1,82 @@
+import { EventEmitter } from 'node:events'
+import { describe, expect, it } from 'vitest'
+import { readApnsStreamResponse, type ApnsResponseStream } from './apns-stream-response.js'
+
+type FakeStream = ApnsResponseStream & {
+ sentBody: string | null
+ destroyedWith: Error | null
+ fireTimeout(): void
+}
+
+function fakeApnsStream(): FakeStream {
+ const emitter = new EventEmitter() as FakeStream
+ emitter.sentBody = null
+ emitter.destroyedWith = null
+ let onTimeout: (() => void) | null = null
+ emitter.setTimeout = (_ms, callback) => {
+ onTimeout = callback
+ }
+ emitter.destroy = (error?: Error) => {
+ emitter.destroyedWith = error ?? null
+ if (error) emitter.emit('error', error)
+ }
+ emitter.end = (body: string) => {
+ emitter.sentBody = body
+ }
+ emitter.fireTimeout = () => onTimeout?.()
+ return emitter
+}
+
+describe('apns stream response', () => {
+ it('resolves with the status and the concatenated body', async () => {
+ const stream = fakeApnsStream()
+ const pending = readApnsStreamResponse(stream, '{"aps":{}}')
+ expect(stream.sentBody).toBe('{"aps":{}}')
+ stream.emit('response', { ':status': '200' })
+ stream.emit('data', Buffer.from('{"re'))
+ stream.emit('data', Buffer.from('ason":"ok"}'))
+ stream.emit('end')
+ await expect(pending).resolves.toEqual({ status: 200, body: '{"reason":"ok"}' })
+ })
+
+ it('rejects when the peer resets the stream without an end or an error', async () => {
+ const stream = fakeApnsStream()
+ const pending = readApnsStreamResponse(stream, 'body')
+ stream.emit('response', { ':status': '200' })
+ // NGHTTP2_NO_ERROR: node emits only 'close', so nothing else would settle.
+ stream.emit('close')
+ await expect(pending).rejects.toThrow('apns_stream_closed')
+ })
+
+ it('keeps the resolved response when close follows a completed end', async () => {
+ const stream = fakeApnsStream()
+ const pending = readApnsStreamResponse(stream, 'body')
+ stream.emit('response', { ':status': '410' })
+ stream.emit('end')
+ stream.emit('close')
+ await expect(pending).resolves.toEqual({ status: 410, body: '' })
+ })
+
+ it('keeps the original error when close follows a stream error', async () => {
+ const stream = fakeApnsStream()
+ const pending = readApnsStreamResponse(stream, 'body')
+ stream.emit('error', new Error('socket_hang_up'))
+ stream.emit('close')
+ await expect(pending).rejects.toThrow('socket_hang_up')
+ })
+
+ it('destroys the stream on timeout and surfaces the timeout error', async () => {
+ const stream = fakeApnsStream()
+ const pending = readApnsStreamResponse(stream, 'body', 10)
+ stream.fireTimeout()
+ await expect(pending).rejects.toThrow('apns_timeout')
+ expect(stream.destroyedWith?.message).toBe('apns_timeout')
+ })
+
+ it('reports a missing status header as zero rather than NaN', async () => {
+ const stream = fakeApnsStream()
+ const pending = readApnsStreamResponse(stream, 'body')
+ stream.emit('end')
+ await expect(pending).resolves.toEqual({ status: 0, body: '' })
+ })
+})
diff --git a/cloud/apps/push/src/apns-stream-response.ts b/cloud/apps/push/src/apns-stream-response.ts
new file mode 100644
index 00000000000..da001a5df31
--- /dev/null
+++ b/cloud/apps/push/src/apns-stream-response.ts
@@ -0,0 +1,53 @@
+import type { EventEmitter } from 'node:events'
+import { providerRetryAfter } from './provider-retry-delay.js'
+import { constants } from 'node:http2'
+
+export type ApnsResponse = { status: number; body: string; retryAfterMs?: number }
+
+// The subset of ClientHttp2Stream this module drives, so a fake emitter can
+// stand in for a real APNs stream in tests.
+export type ApnsResponseStream = EventEmitter & {
+ setTimeout(ms: number, callback: () => void): void
+ destroy(error?: Error): void
+ end(body: string): void
+}
+
+export const APNS_REQUEST_TIMEOUT_MS = 10_000
+
+export function readApnsStreamResponse(
+ stream: ApnsResponseStream,
+ body: string,
+ timeoutMs = APNS_REQUEST_TIMEOUT_MS
+): Promise {
+ return new Promise((resolve, reject) => {
+ let settled = false
+ const settle = (run: () => void): void => {
+ if (settled) return
+ settled = true
+ run()
+ }
+ let status = 0
+ let retryAfterMs: number | undefined
+ const chunks: Buffer[] = []
+ stream.setTimeout(timeoutMs, () => stream.destroy(new Error('apns_timeout')))
+ stream.on('response', (headers: Record) => {
+ status = Number(headers[constants.HTTP2_HEADER_STATUS] ?? 0)
+ retryAfterMs = providerRetryAfter(String(headers['retry-after'] ?? ''))
+ })
+ stream.on('data', (chunk: Buffer) => chunks.push(chunk))
+ stream.on('error', (error: Error) => settle(() => reject(error)))
+ stream.on('end', () =>
+ settle(() =>
+ resolve({
+ status,
+ body: Buffer.concat(chunks).toString('utf8'),
+ ...(retryAfterMs === undefined ? {} : { retryAfterMs })
+ })
+ )
+ )
+ // A peer reset with NGHTTP2_NO_ERROR emits neither 'end' nor 'error', which
+ // would leave the coalescer's delivery pending for the life of the process.
+ stream.on('close', () => settle(() => reject(new Error('apns_stream_closed'))))
+ stream.end(body)
+ })
+}
diff --git a/cloud/apps/push/src/canonical-base64.ts b/cloud/apps/push/src/canonical-base64.ts
new file mode 100644
index 00000000000..e13ea982cb6
--- /dev/null
+++ b/cloud/apps/push/src/canonical-base64.ts
@@ -0,0 +1,9 @@
+// Rejects the many base64 spellings of the same bytes: a non-canonical
+// encoding would change the transcript the host signs without changing the key.
+export function decodeCanonicalBase64(value: string, expectedBytes: number): Buffer | null {
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return null
+ const decoded = Buffer.from(value, 'base64')
+ return decoded.byteLength === expectedBytes && decoded.toString('base64') === value
+ ? decoded
+ : null
+}
diff --git a/cloud/apps/push/src/client-ip-rate-limit.test.ts b/cloud/apps/push/src/client-ip-rate-limit.test.ts
new file mode 100644
index 00000000000..2fc3734adc2
--- /dev/null
+++ b/cloud/apps/push/src/client-ip-rate-limit.test.ts
@@ -0,0 +1,145 @@
+import { PUSH_LIMITS } from '@orca-cloud/push-contract'
+import { Hono } from 'hono'
+import { describe, expect, it } from 'vitest'
+import { ClientIpRateLimiter, clientIpRateLimit } from './client-ip-rate-limit.js'
+
+const CAPACITY = PUSH_LIMITS.unauthenticatedRequestsPerMinutePerIp
+
+function limiterApp(limiter: ClientIpRateLimiter, trustedProxyHops = 0): Hono {
+ const app = new Hono()
+ app.post('/probe', clientIpRateLimit(limiter, { trustedProxyHops }), (context) =>
+ context.json({ ok: true })
+ )
+ return app
+}
+
+describe('client ip rate limiter', () => {
+ it('admits exactly the per-minute allowance and refuses the next request', () => {
+ const limiter = new ClientIpRateLimiter({ now: () => 1_000 })
+ for (let index = 0; index < CAPACITY; index++) {
+ expect(limiter.allow('203.0.113.7')).toBe(true)
+ }
+ expect(limiter.allow('203.0.113.7')).toBe(false)
+ })
+
+ it('keeps one client ip from spending another one budget', () => {
+ const limiter = new ClientIpRateLimiter({ now: () => 1_000 })
+ for (let index = 0; index < CAPACITY; index++) limiter.allow('203.0.113.7')
+ expect(limiter.allow('203.0.113.7')).toBe(false)
+ expect(limiter.allow('198.51.100.9')).toBe(true)
+ })
+
+ it('refills over the window rather than resetting on a boundary', () => {
+ let clock = 1_000
+ const limiter = new ClientIpRateLimiter({ now: () => clock })
+ for (let index = 0; index < CAPACITY; index++) limiter.allow('203.0.113.7')
+ expect(limiter.allow('203.0.113.7')).toBe(false)
+
+ // Half a window buys back half the allowance, no more.
+ clock += 30_000
+ for (let index = 0; index < CAPACITY / 2; index++) {
+ expect(limiter.allow('203.0.113.7')).toBe(true)
+ }
+ expect(limiter.allow('203.0.113.7')).toBe(false)
+ })
+
+ it('bounds what it remembers when a flood of distinct ips arrives', () => {
+ let clock = 1_000
+ const limiter = new ClientIpRateLimiter({ now: () => clock, maxTrackedIps: 8 })
+ for (let index = 0; index < 200; index++) {
+ clock += 1
+ limiter.allow(`198.51.100.${index}`)
+ }
+ expect(limiter.trackedIpCount()).toBeLessThanOrEqual(8)
+ })
+
+ it('answers 429 with a rate_limited body once the bucket is empty', async () => {
+ const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000 }))
+ const headers = { 'x-forwarded-for': '10.0.0.1, 10.0.0.2, 203.0.113.7' }
+ for (let index = 0; index < CAPACITY; index++) {
+ expect((await app.request('/probe', { method: 'POST', headers })).status).toBe(200)
+ }
+ const limited = await app.request('/probe', { method: 'POST', headers })
+ expect(limited.status).toBe(429)
+ expect(await limited.json()).toEqual({ error: 'rate_limited' })
+ })
+
+ it('buckets on the last forwarded hop, the only one the platform appended', async () => {
+ const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000 }))
+ for (let index = 0; index < CAPACITY; index++) {
+ await app.request('/probe', {
+ method: 'POST',
+ headers: { 'x-forwarded-for': `10.0.0.${index}, 203.0.113.7` }
+ })
+ }
+ const sameClient = await app.request('/probe', {
+ method: 'POST',
+ headers: { 'x-forwarded-for': '10.9.9.9, 203.0.113.7' }
+ })
+ expect(sameClient.status).toBe(429)
+ const otherClient = await app.request('/probe', {
+ method: 'POST',
+ headers: { 'x-forwarded-for': '10.0.0.1, 198.51.100.9' }
+ })
+ expect(otherClient.status).toBe(200)
+ })
+
+ it('gives a spoofed left-most hop no escape from the caller own bucket', async () => {
+ const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000 }))
+ // A caller that rewrites its own x-forwarded-for on every request still ends
+ // up behind the one value Cloud Run appended.
+ for (let index = 0; index < CAPACITY; index++) {
+ const allowed = await app.request('/probe', {
+ method: 'POST',
+ headers: { 'x-forwarded-for': `198.51.100.${index}, 203.0.113.7` }
+ })
+ expect(allowed.status).toBe(200)
+ }
+ const spoofed = await app.request('/probe', {
+ method: 'POST',
+ headers: { 'x-forwarded-for': '198.51.100.250, 10.1.1.1, 203.0.113.7' }
+ })
+ expect(spoofed.status).toBe(429)
+ })
+
+ it('skips the configured trusted proxies when counting from the right', async () => {
+ const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000, capacity: 1 }), 1)
+ // , , : one trusted hop after the client.
+ const headers = { 'x-forwarded-for': '203.0.113.7, 10.0.0.1' }
+ expect((await app.request('/probe', { method: 'POST', headers })).status).toBe(200)
+ expect((await app.request('/probe', { method: 'POST', headers })).status).toBe(429)
+ expect(
+ (await app.request('/probe', {
+ method: 'POST',
+ headers: { 'x-forwarded-for': '198.51.100.9, 10.0.0.1' }
+ })).status
+ ).toBe(200)
+ })
+
+ it('trusts nothing when the header is shorter than the configured depth', async () => {
+ const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000, capacity: 1 }), 1)
+ // Only one hop, so the client value the depth points at does not exist.
+ const headers = { 'x-forwarded-for': '203.0.113.7' }
+ expect((await app.request('/probe', { method: 'POST', headers })).status).toBe(200)
+ expect(
+ (await app.request('/probe', {
+ method: 'POST',
+ headers: { 'x-forwarded-for': '198.51.100.9' }
+ })).status
+ ).toBe(429)
+ })
+
+ it('falls back to x-real-ip and then to a single shared bucket', async () => {
+ const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000, capacity: 1 }))
+ expect(
+ (await app.request('/probe', { method: 'POST', headers: { 'x-real-ip': '203.0.113.7' } }))
+ .status
+ ).toBe(200)
+ expect(
+ (await app.request('/probe', { method: 'POST', headers: { 'x-real-ip': '203.0.113.7' } }))
+ .status
+ ).toBe(429)
+ expect((await app.request('/probe', { method: 'POST' })).status).toBe(200)
+ expect((await app.request('/probe', { method: 'POST' })).status).toBe(429)
+ })
+})
diff --git a/cloud/apps/push/src/client-ip-rate-limit.ts b/cloud/apps/push/src/client-ip-rate-limit.ts
new file mode 100644
index 00000000000..efc26a7ea78
--- /dev/null
+++ b/cloud/apps/push/src/client-ip-rate-limit.ts
@@ -0,0 +1,110 @@
+import { PUSH_LIMITS } from '@orca-cloud/push-contract'
+import type { Context, MiddlewareHandler } from 'hono'
+
+const REFILL_WINDOW_MS = 60_000
+const MAX_TRACKED_IPS = 10_000
+const UNKNOWN_CLIENT_IP = 'unknown'
+
+export type ClientIpRateLimiterOptions = {
+ capacity?: number
+ windowMs?: number
+ maxTrackedIps?: number
+ now?: () => number
+}
+
+type Bucket = { tokens: number; updatedAt: number }
+
+// Read x-forwarded-for from the right. Cloud Run appends the connecting peer,
+// so the last value is the only one it wrote; everything to its left is
+// whatever the caller sent and can be a fresh forgery on every request.
+// trustedProxyHops is how many appenders sit between Cloud Run and the client
+// (0 today, 1 once a load balancer fronts it). A header too short for that
+// depth is not trusted at all and falls through to the shared bucket, which
+// throttles rather than opens.
+export function readClientIp(context: Context, trustedProxyHops = 0): string {
+ const hops =
+ context.req
+ .header('x-forwarded-for')
+ ?.split(',')
+ .map((hop) => hop.trim())
+ .filter((hop) => hop.length > 0) ?? []
+ const client = hops[hops.length - 1 - trustedProxyHops]
+ if (client) return client
+ return context.req.header('x-real-ip')?.trim() || UNKNOWN_CLIENT_IP
+}
+
+// In-memory and per-instance on purpose. A shared counter would put a database
+// round trip in front of the only routes an attacker can reach unauthenticated,
+// and Cloud Run's instance fan-out only loosens the cap by the instance count.
+export class ClientIpRateLimiter {
+ private readonly buckets = new Map()
+ private readonly capacity: number
+ private readonly windowMs: number
+ private readonly maxTrackedIps: number
+ private readonly now: () => number
+
+ constructor(options: ClientIpRateLimiterOptions = {}) {
+ this.capacity = options.capacity ?? PUSH_LIMITS.unauthenticatedRequestsPerMinutePerIp
+ this.windowMs = options.windowMs ?? REFILL_WINDOW_MS
+ this.maxTrackedIps = options.maxTrackedIps ?? MAX_TRACKED_IPS
+ this.now = options.now ?? Date.now
+ }
+
+ allow(clientIp: string): boolean {
+ const now = this.now()
+ const tokens = this.tokensAt(this.buckets.get(clientIp), now)
+ if (tokens < 1) {
+ this.buckets.set(clientIp, { tokens, updatedAt: now })
+ return false
+ }
+ this.buckets.set(clientIp, { tokens: tokens - 1, updatedAt: now })
+ this.evict(now)
+ return true
+ }
+
+ trackedIpCount(): number {
+ return this.buckets.size
+ }
+
+ private tokensAt(bucket: Bucket | undefined, now: number): number {
+ if (!bucket) return this.capacity
+ const refilled = ((now - bucket.updatedAt) * this.capacity) / this.windowMs
+ return Math.min(this.capacity, bucket.tokens + Math.max(0, refilled))
+ }
+
+ private evict(now: number): void {
+ if (this.buckets.size <= this.maxTrackedIps) return
+ // A bucket that has refilled to capacity is indistinguishable from an
+ // absent one, so dropping it changes no decision.
+ for (const [clientIp, bucket] of this.buckets) {
+ if (this.tokensAt(bucket, now) >= this.capacity) this.buckets.delete(clientIp)
+ }
+ if (this.buckets.size <= this.maxTrackedIps) return
+ // A flood of distinct live IPs can still overflow. The least recently seen
+ // are the least likely to be mid-burst.
+ const excess = [...this.buckets.entries()]
+ .sort((left, right) => left[1].updatedAt - right[1].updatedAt)
+ .slice(0, this.buckets.size - this.maxTrackedIps)
+ for (const [clientIp] of excess) this.buckets.delete(clientIp)
+ }
+}
+
+export type ClientIpRateLimitOptions = {
+ trustedProxyHops?: number
+ onLimited?: () => void
+}
+
+export function clientIpRateLimit(
+ limiter: ClientIpRateLimiter,
+ options: ClientIpRateLimitOptions = {}
+): MiddlewareHandler {
+ const trustedProxyHops = options.trustedProxyHops ?? 0
+ return async (context, next) => {
+ if (!limiter.allow(readClientIp(context, trustedProxyHops))) {
+ options.onLimited?.()
+ return context.json({ error: 'rate_limited' }, 429)
+ }
+ await next()
+ return
+ }
+}
diff --git a/cloud/apps/push/src/coalescer.test.ts b/cloud/apps/push/src/coalescer.test.ts
new file mode 100644
index 00000000000..5fcf8f3342c
--- /dev/null
+++ b/cloud/apps/push/src/coalescer.test.ts
@@ -0,0 +1,173 @@
+import type { PushNotification } from '@orca-cloud/push-contract'
+import { describe, expect, it } from 'vitest'
+import { PushCoalescer, summaryBody, type CoalescerTimer } from './coalescer.js'
+import type { PushDelivery } from './push-delivery-message.js'
+
+const HOST = 'abcdefghijklmnop'
+
+function notification(overrides: Partial = {}): PushNotification {
+ return {
+ notificationId: 'note-1',
+ notificationSeq: 1,
+ notificationEpoch: 'epoch-1',
+ source: 'agent-task-complete',
+ agentState: 'needs-input',
+ title: 'Agent needs input',
+ body: 'Waiting on your answer',
+ worktreeId: 'wt-1',
+ ...overrides
+ }
+}
+
+// A manual timer queue so a 3s window is exercised without waiting 3s.
+function createTimerHarness() {
+ const pending = new Map void>()
+ let nextId = 0
+ return {
+ delays: [] as number[],
+ setTimer(callback: () => void, delayMs: number): CoalescerTimer {
+ const handle = nextId++
+ pending.set(handle, callback)
+ this.delays.push(delayMs)
+ return { handle }
+ },
+ clearTimer(timer: CoalescerTimer): void {
+ pending.delete(timer.handle as number)
+ },
+ fireAll(): void {
+ for (const callback of [...pending.values()]) callback()
+ }
+ }
+}
+
+function createCoalescer(windowMs = 3_000) {
+ const timers = createTimerHarness()
+ const delivered: PushDelivery[] = []
+ const coalescer = new PushCoalescer({
+ windowMs,
+ deliver: async (delivery) => {
+ delivered.push(delivery)
+ },
+ setTimer: (callback, delayMs) => timers.setTimer(callback, delayMs),
+ clearTimer: (timer) => timers.clearTimer(timer)
+ })
+ return { coalescer, delivered, timers }
+}
+
+describe('push coalescer', () => {
+ it('sends a single event unchanged with the notification collapse id', async () => {
+ const { coalescer, delivered, timers } = createCoalescer()
+ coalescer.enqueue({ registrationId: 'reg-1', hostFingerprint: HOST, notification: notification() })
+ expect(timers.delays).toEqual([3_000])
+ expect(delivered).toHaveLength(0)
+ await coalescer.flush('reg-1')
+ expect(delivered).toHaveLength(1)
+ expect(delivered[0]).toMatchObject({
+ registrationId: 'reg-1',
+ title: 'Agent needs input',
+ body: 'Waiting on your answer',
+ collapseId: 'note-1'
+ })
+ expect(delivered[0]?.orca).toMatchObject({
+ hostFingerprint: HOST,
+ notificationId: 'note-1',
+ notificationSeq: 1,
+ worktreeId: 'wt-1',
+ coalescedCount: 1
+ })
+ })
+
+ it('falls back to the host collapse id when the event carries no notification id', async () => {
+ const { coalescer, delivered } = createCoalescer()
+ const { notificationId: _absent, ...bell } = notification({ source: 'terminal-bell' })
+ coalescer.enqueue({
+ registrationId: 'reg-1',
+ hostFingerprint: HOST,
+ notification: { ...bell, agentState: null }
+ })
+ await coalescer.flush('reg-1')
+ expect(delivered[0]?.collapseId).toBe(`host:${HOST}`)
+ expect(delivered[0]?.orca.notificationId).toBeUndefined()
+ })
+
+ it('summarises a burst and collapses it under the host id', async () => {
+ const { coalescer, delivered } = createCoalescer()
+ for (const seq of [1, 2, 3]) {
+ coalescer.enqueue({
+ registrationId: 'reg-1',
+ hostFingerprint: HOST,
+ notification: notification({ notificationId: `note-${seq}`, notificationSeq: seq })
+ })
+ }
+ expect(coalescer.pendingCount('reg-1')).toBe(3)
+ await coalescer.flush('reg-1')
+ expect(delivered).toHaveLength(1)
+ expect(delivered[0]).toMatchObject({
+ title: 'Orca',
+ body: '3 agents need attention',
+ collapseId: `host:${HOST}`
+ })
+ // The data carries the latest event, so a tap still opens the newest work.
+ expect(delivered[0]?.orca).toMatchObject({
+ notificationId: 'note-3',
+ notificationSeq: 3,
+ coalescedCount: 3
+ })
+ })
+
+ it('says updates when no event in the burst needs input', async () => {
+ const { coalescer, delivered } = createCoalescer()
+ for (const seq of [1, 2]) {
+ coalescer.enqueue({
+ registrationId: 'reg-1',
+ hostFingerprint: HOST,
+ notification: notification({ notificationSeq: seq, agentState: 'finished' })
+ })
+ }
+ await coalescer.flush('reg-1')
+ expect(delivered[0]?.body).toBe('2 updates')
+ expect(summaryBody([notification({ agentState: null }), notification({ agentState: null })]))
+ .toBe('2 updates')
+ })
+
+ it('keeps one window per registration', async () => {
+ const { coalescer, delivered, timers } = createCoalescer()
+ coalescer.enqueue({ registrationId: 'reg-1', hostFingerprint: HOST, notification: notification() })
+ coalescer.enqueue({ registrationId: 'reg-2', hostFingerprint: HOST, notification: notification() })
+ coalescer.enqueue({ registrationId: 'reg-1', hostFingerprint: HOST, notification: notification() })
+ expect(timers.delays).toHaveLength(2)
+ await coalescer.flushAll()
+ expect(delivered.map((delivery) => delivery.registrationId).sort()).toEqual(['reg-1', 'reg-2'])
+ expect(delivered.find((d) => d.registrationId === 'reg-1')?.orca.coalescedCount).toBe(2)
+ expect(delivered.find((d) => d.registrationId === 'reg-2')?.orca.coalescedCount).toBe(1)
+ })
+
+ it('flushes when the window timer fires and starts a fresh window after', async () => {
+ const { coalescer, delivered, timers } = createCoalescer()
+ coalescer.enqueue({ registrationId: 'reg-1', hostFingerprint: HOST, notification: notification() })
+ timers.fireAll()
+ await Promise.resolve()
+ expect(delivered).toHaveLength(1)
+ coalescer.enqueue({ registrationId: 'reg-1', hostFingerprint: HOST, notification: notification() })
+ expect(coalescer.pendingCount('reg-1')).toBe(1)
+ await coalescer.flushAll()
+ expect(delivered).toHaveLength(2)
+ })
+
+ it('reports a delivery failure instead of throwing into the caller', async () => {
+ const failures: unknown[] = []
+ const coalescer = new PushCoalescer({
+ windowMs: 0,
+ deliver: async () => {
+ throw new Error('provider down')
+ },
+ setTimer: () => ({ handle: null }),
+ clearTimer: () => undefined,
+ onDeliveryFailed: (error) => failures.push(error)
+ })
+ coalescer.enqueue({ registrationId: 'reg-1', hostFingerprint: HOST, notification: notification() })
+ await expect(coalescer.flush('reg-1')).resolves.toBeUndefined()
+ expect(failures).toHaveLength(1)
+ coalescer.stop()
+ })
+})
diff --git a/cloud/apps/push/src/coalescer.ts b/cloud/apps/push/src/coalescer.ts
new file mode 100644
index 00000000000..f55b6757418
--- /dev/null
+++ b/cloud/apps/push/src/coalescer.ts
@@ -0,0 +1,117 @@
+import { PUSH_LIMITS, type PushNotification } from '@orca-cloud/push-contract'
+import { buildPushDelivery, type PushDelivery } from './push-delivery-message.js'
+
+export type CoalescerTimer = { readonly handle: unknown }
+
+export type PushCoalescerOptions = {
+ windowMs?: number
+ deliver: (delivery: PushDelivery) => Promise
+ setTimer?: (callback: () => void, delayMs: number) => CoalescerTimer
+ clearTimer?: (timer: CoalescerTimer) => void
+ onDeliveryFailed?: (error: unknown) => void
+}
+
+type PendingWindow = {
+ hostFingerprint: string
+ notifications: PushNotification[]
+ timer: CoalescerTimer
+}
+
+function defaultSetTimer(callback: () => void, delayMs: number): CoalescerTimer {
+ const handle = setTimeout(callback, delayMs)
+ handle.unref?.()
+ return { handle }
+}
+
+function defaultClearTimer(timer: CoalescerTimer): void {
+ clearTimeout(timer.handle as NodeJS.Timeout)
+}
+
+export function summaryBody(notifications: readonly PushNotification[]): string {
+ const count = notifications.length
+ return notifications.some((notification) => notification.agentState === 'needs-input')
+ ? `${count} agents need attention`
+ : `${count} updates`
+}
+
+// Holds sends per registration for one window so a burst of desktop events
+// reaches the phone as a single banner instead of a stack of near-duplicates.
+export class PushCoalescer {
+ private readonly deliveries = new Set>()
+ private stopped = false
+ private readonly windows = new Map()
+ private readonly windowMs: number
+ private readonly setTimer: (callback: () => void, delayMs: number) => CoalescerTimer
+ private readonly clearTimer: (timer: CoalescerTimer) => void
+
+ constructor(private readonly options: PushCoalescerOptions) {
+ this.windowMs = options.windowMs ?? PUSH_LIMITS.coalesceWindowMs
+ this.setTimer = options.setTimer ?? defaultSetTimer
+ this.clearTimer = options.clearTimer ?? defaultClearTimer
+ }
+
+ enqueue(input: {
+ registrationId: string
+ hostFingerprint: string
+ notification: PushNotification
+ }): void {
+ if (this.stopped) throw new Error('push_coalescer_stopped')
+ const existing = this.windows.get(input.registrationId)
+ if (existing) {
+ existing.notifications.push(input.notification)
+ return
+ }
+ this.windows.set(input.registrationId, {
+ hostFingerprint: input.hostFingerprint,
+ notifications: [input.notification],
+ timer: this.setTimer(() => {
+ void this.flush(input.registrationId)
+ }, this.windowMs)
+ })
+ }
+
+ pendingCount(registrationId: string): number {
+ return this.windows.get(registrationId)?.notifications.length ?? 0
+ }
+
+ async flush(registrationId: string): Promise {
+ const window = this.windows.get(registrationId)
+ if (!window) return
+ this.windows.delete(registrationId)
+ this.clearTimer(window.timer)
+ const latest = window.notifications.at(-1)!
+ const coalescedCount = window.notifications.length
+ const delivery = buildPushDelivery({
+ registrationId,
+ hostFingerprint: window.hostFingerprint,
+ notification: latest,
+ title: coalescedCount > 1 ? 'Orca' : latest.title,
+ body: coalescedCount > 1 ? summaryBody(window.notifications) : latest.body,
+ coalescedCount
+ })
+ const pending = Promise.resolve()
+ .then(() => this.options.deliver(delivery))
+ .catch((error) => {
+ this.options.onDeliveryFailed?.(error)
+ })
+ this.deliveries.add(pending)
+ try {
+ await pending
+ } finally {
+ this.deliveries.delete(pending)
+ }
+ }
+
+ async flushAll(): Promise {
+ do {
+ await Promise.all([...this.windows.keys()].map((id) => this.flush(id)))
+ await Promise.all([...this.deliveries])
+ } while (this.windows.size || this.deliveries.size)
+ }
+
+ stop(): void {
+ this.stopped = true
+ for (const window of this.windows.values()) this.clearTimer(window.timer)
+ this.windows.clear()
+ }
+}
diff --git a/cloud/apps/push/src/config.test.ts b/cloud/apps/push/src/config.test.ts
new file mode 100644
index 00000000000..857022a63a3
--- /dev/null
+++ b/cloud/apps/push/src/config.test.ts
@@ -0,0 +1,90 @@
+import { generateKeyPairSync } from 'node:crypto'
+import { PUSH_DEFAULTS, PUSH_LIMITS } from '@orca-cloud/push-contract'
+import { describe, expect, it } from 'vitest'
+import { loadPushConfig, PUSH_DATABASE_POOL_MAX } from './config.js'
+
+function apnsKeyPem(): string {
+ return generateKeyPairSync('ec', {
+ namedCurve: 'P-256',
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+ publicKeyEncoding: { type: 'spki', format: 'pem' }
+ }).privateKey
+}
+
+const MINIMAL = { ORCA_PUSH_PUBLIC_URL: 'https://push.onorca.dev' }
+
+describe('push gateway config', () => {
+ it('applies the documented defaults', () => {
+ expect(loadPushConfig(MINIMAL)).toEqual({
+ port: 8080,
+ publicUrl: 'https://push.onorca.dev',
+ databaseUrl: undefined,
+ dataDir: './data/push',
+ databasePoolMax: PUSH_DATABASE_POOL_MAX,
+ apns: undefined,
+ apnsTopic: PUSH_DEFAULTS.apnsTopic,
+ fcmProjectId: PUSH_DEFAULTS.fcmProjectId,
+ coalesceMs: PUSH_LIMITS.coalesceWindowMs,
+ trustedProxyHops: 0
+ })
+ })
+
+ it('reads a full APNs credential and the overridable knobs', () => {
+ const keyPem = apnsKeyPem()
+ const config = loadPushConfig({
+ ...MINIMAL,
+ PORT: '9090',
+ ORCA_PUSH_DATABASE_URL: 'postgres://localhost/orca_push',
+ ORCA_PUSH_DATA_DIR: '/var/lib/push',
+ ORCA_PUSH_APNS_KEY: keyPem,
+ ORCA_PUSH_APNS_KEY_ID: 'ABCDE12345',
+ ORCA_PUSH_APPLE_TEAM_ID: 'TEAM123456',
+ ORCA_PUSH_APNS_TOPIC: 'com.stably.orca.mobile.dev',
+ ORCA_PUSH_FCM_PROJECT_ID: 'onorca-staging',
+ ORCA_PUSH_COALESCE_MS: '1500',
+ ORCA_PUSH_TRUSTED_PROXY_HOPS: '1'
+ })
+ expect(config).toMatchObject({
+ port: 9090,
+ databaseUrl: 'postgres://localhost/orca_push',
+ dataDir: '/var/lib/push',
+ apns: { keyPem, keyId: 'ABCDE12345', teamId: 'TEAM123456' },
+ apnsTopic: 'com.stably.orca.mobile.dev',
+ trustedProxyHops: 1,
+ fcmProjectId: 'onorca-staging',
+ coalesceMs: 1500
+ })
+ })
+
+ it('refuses a partial APNs credential', () => {
+ expect(() =>
+ loadPushConfig({ ...MINIMAL, ORCA_PUSH_APNS_KEY: apnsKeyPem() })
+ ).toThrow('configured together')
+ expect(() =>
+ loadPushConfig({
+ ...MINIMAL,
+ ORCA_PUSH_APNS_KEY: 'not-a-pem',
+ ORCA_PUSH_APNS_KEY_ID: 'ABCDE12345',
+ ORCA_PUSH_APPLE_TEAM_ID: 'TEAM123456'
+ })
+ ).toThrow('PEM text')
+ })
+
+ it('requires a canonical HTTPS origin outside loopback', () => {
+ expect(() => loadPushConfig({ ORCA_PUSH_PUBLIC_URL: 'https://push.onorca.dev/v1' })).toThrow(
+ 'must be an origin'
+ )
+ expect(() => loadPushConfig({ ORCA_PUSH_PUBLIC_URL: 'http://push.onorca.dev' })).toThrow(
+ 'must use HTTPS'
+ )
+ expect(loadPushConfig({ ORCA_PUSH_PUBLIC_URL: 'http://localhost:8080' }).publicUrl).toBe(
+ 'http://localhost:8080'
+ )
+ })
+
+ it('treats an empty optional variable as unset', () => {
+ expect(
+ loadPushConfig({ ...MINIMAL, ORCA_PUSH_DATABASE_URL: '', ORCA_PUSH_APNS_KEY_ID: '' })
+ ).toMatchObject({ databaseUrl: undefined, apns: undefined })
+ })
+})
diff --git a/cloud/apps/push/src/config.ts b/cloud/apps/push/src/config.ts
new file mode 100644
index 00000000000..08ec608e528
--- /dev/null
+++ b/cloud/apps/push/src/config.ts
@@ -0,0 +1,105 @@
+import { PUSH_DEFAULTS, PUSH_LIMITS } from '@orca-cloud/push-contract'
+import { z } from 'zod'
+
+export const PUSH_DATABASE_POOL_MAX = 10
+
+const OptionalTextSchema = z.preprocess(
+ (value) => (value === '' ? undefined : value),
+ z.string().min(1).optional()
+)
+
+const EnvSchema = z.object({
+ PORT: z.coerce.number().int().positive().default(8080),
+ ORCA_PUSH_PUBLIC_URL: z.string().url(),
+ ORCA_PUSH_DATABASE_URL: OptionalTextSchema,
+ ORCA_PUSH_DATA_DIR: z.string().min(1).default('./data/push'),
+ ORCA_PUSH_DATABASE_POOL_MAX: z.coerce.number().int().positive().max(100).optional(),
+ ORCA_PUSH_APNS_KEY: OptionalTextSchema,
+ ORCA_PUSH_APNS_KEY_ID: z.preprocess(
+ (value) => (value === '' ? undefined : value),
+ z.string().regex(/^[A-Z0-9]{10}$/).optional()
+ ),
+ ORCA_PUSH_APPLE_TEAM_ID: z.preprocess(
+ (value) => (value === '' ? undefined : value),
+ z.string().regex(/^[A-Z0-9]{10}$/).optional()
+ ),
+ ORCA_PUSH_APNS_TOPIC: z.string().min(1).max(255).default(PUSH_DEFAULTS.apnsTopic),
+ ORCA_PUSH_FCM_PROJECT_ID: z
+ .string()
+ .regex(/^[a-z0-9-]{4,64}$/)
+ .default(PUSH_DEFAULTS.fcmProjectId),
+ ORCA_PUSH_COALESCE_MS: z.coerce
+ .number()
+ .int()
+ .nonnegative()
+ .max(60_000)
+ .default(PUSH_LIMITS.coalesceWindowMs),
+ // How many proxies append to x-forwarded-for after the client. 0 is Cloud Run
+ // alone; raise it to 1 when a load balancer fronts the service.
+ ORCA_PUSH_TRUSTED_PROXY_HOPS: z.coerce.number().int().nonnegative().max(8).default(0)
+})
+
+export type ApnsCredentials = { keyPem: string; keyId: string; teamId: string }
+
+export type PushConfig = {
+ port: number
+ publicUrl: string
+ databaseUrl?: string
+ dataDir: string
+ databasePoolMax: number
+ apns?: ApnsCredentials
+ apnsTopic: string
+ fcmProjectId: string
+ coalesceMs: number
+ trustedProxyHops: number
+}
+
+function canonicalOrigin(value: string, name: string): string {
+ const url = new URL(value)
+ if (url.origin !== value || url.pathname !== '/') throw new Error(`${name} must be an origin`)
+ const loopback = ['127.0.0.1', 'localhost', '::1', '[::1]'].includes(url.hostname)
+ if (url.protocol !== 'https:' && !(loopback && url.protocol === 'http:')) {
+ throw new Error(`${name} must use HTTPS outside loopback development`)
+ }
+ return value
+}
+
+// The APNs key, key id, and team id are one credential; a partial set would
+// pass startup and then fail every iOS send at runtime.
+function readApnsCredentials(
+ parsed: z.infer
+): ApnsCredentials | undefined {
+ const parts = [
+ parsed.ORCA_PUSH_APNS_KEY,
+ parsed.ORCA_PUSH_APNS_KEY_ID,
+ parsed.ORCA_PUSH_APPLE_TEAM_ID
+ ]
+ const present = parts.filter((value) => value !== undefined).length
+ if (present === 0) return undefined
+ if (present !== parts.length) {
+ throw new Error('APNs key, key id, and team id must be configured together')
+ }
+ const keyPem = parsed.ORCA_PUSH_APNS_KEY!
+ if (!keyPem.includes('-----BEGIN')) throw new Error('ORCA_PUSH_APNS_KEY must be PEM text')
+ return {
+ keyPem,
+ keyId: parsed.ORCA_PUSH_APNS_KEY_ID!,
+ teamId: parsed.ORCA_PUSH_APPLE_TEAM_ID!
+ }
+}
+
+export function loadPushConfig(env: NodeJS.ProcessEnv = process.env): PushConfig {
+ const parsed = EnvSchema.parse(env)
+ return {
+ port: parsed.PORT,
+ publicUrl: canonicalOrigin(parsed.ORCA_PUSH_PUBLIC_URL, 'ORCA_PUSH_PUBLIC_URL'),
+ databaseUrl: parsed.ORCA_PUSH_DATABASE_URL,
+ dataDir: parsed.ORCA_PUSH_DATA_DIR,
+ databasePoolMax: parsed.ORCA_PUSH_DATABASE_POOL_MAX ?? PUSH_DATABASE_POOL_MAX,
+ apns: readApnsCredentials(parsed),
+ apnsTopic: parsed.ORCA_PUSH_APNS_TOPIC,
+ fcmProjectId: parsed.ORCA_PUSH_FCM_PROJECT_ID,
+ coalesceMs: parsed.ORCA_PUSH_COALESCE_MS,
+ trustedProxyHops: parsed.ORCA_PUSH_TRUSTED_PROXY_HOPS
+ }
+}
diff --git a/cloud/apps/push/src/desktop-host-proof-interop.test.ts b/cloud/apps/push/src/desktop-host-proof-interop.test.ts
new file mode 100644
index 00000000000..654423b0de8
--- /dev/null
+++ b/cloud/apps/push/src/desktop-host-proof-interop.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest'
+import { createHmac } from 'node:crypto'
+import vector from '../../../packages/push-contract/src/push-host-proof-vector.json' with { type: 'json' }
+import { answerPushHostChallenge, createPushHostKeypair } from './host-challenge-answering.test-fixture.js'
+import { PushHostChallengeStore } from './host-challenge-store.js'
+import { deriveHostFingerprint } from './host-fingerprint.js'
+import { openInMemoryPushDatabase } from './push-database.js'
+
+// Why: the desktop answers challenges in a workspace this one cannot import.
+// Both sides replay the same checked-in vector, so a transcript drift on
+// either side fails in that side's own suite.
+describe('desktop host proof interop', () => {
+ it('the checked-in vector answers to the same proof the fixture host computes', () => {
+ const secretKey = new Uint8Array(Buffer.from(vector.hostSecretKeyB64, 'base64'))
+ const keypair = { publicKey: new Uint8Array(Buffer.from(vector.hostPublicKeyB64, 'base64')), secretKey }
+ expect(deriveHostFingerprint(keypair.publicKey)).toBe(vector.hostFingerprint)
+ const proof = answerPushHostChallenge(vector.challenge, {
+ gatewayOrigin: vector.gatewayOrigin,
+ keypair,
+ now: () => vector.issuedAt + 1_000
+ })
+ const expected = createHmac('sha256', Buffer.from(vector.challengeSecretB64, 'base64'))
+ .update(Buffer.from('orca-push-host-proof/v1\0ack\0'))
+ .update(Buffer.from(vector.transcriptB64, 'base64'))
+ .digest('base64')
+ expect(proof).toBe(expected)
+ })
+
+ it('a live challenge from the store round-trips through the fixture host once', async () => {
+ const database = await openInMemoryPushDatabase()
+ const store = new PushHostChallengeStore(database, vector.gatewayOrigin)
+ const keypair = createPushHostKeypair(11)
+ const challenge = await store.issue(Buffer.from(keypair.publicKey).toString('base64'))
+ expect(challenge).not.toBeNull()
+ const proof = answerPushHostChallenge(challenge!, { gatewayOrigin: vector.gatewayOrigin, keypair })
+ expect(proof).not.toBeNull()
+ expect(await store.verify(challenge!.challengeId, proof!)).toEqual({
+ ok: true,
+ hostFingerprint: deriveHostFingerprint(keypair.publicKey)
+ })
+ expect(await store.verify(challenge!.challengeId, proof!)).toEqual({
+ ok: false,
+ reason: 'already_consumed'
+ })
+ await database.close()
+ })
+})
diff --git a/cloud/apps/push/src/device-registry-store.test.ts b/cloud/apps/push/src/device-registry-store.test.ts
new file mode 100644
index 00000000000..f191112f06a
--- /dev/null
+++ b/cloud/apps/push/src/device-registry-store.test.ts
@@ -0,0 +1,205 @@
+import { PUSH_LIMITS, type PushNotificationFilter } from '@orca-cloud/push-contract'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { PushDeviceRegistryStore, type PushDeviceUpsert } from './device-registry-store.js'
+import { openInMemoryPushDatabase, type PushDatabase } from './push-database.js'
+
+const OWNER = 'abcdefghijklmnop'
+const OTHER = 'ponmlkjihgfedcba'
+const FILTER: PushNotificationFilter = {
+ sources: ['agent-task-complete'],
+ agentStates: ['needs-input']
+}
+
+describe('push device registry store', () => {
+ let database: PushDatabase
+ let clock = 1_700_000_000_000
+ let devices: PushDeviceRegistryStore
+
+ beforeEach(async () => {
+ database = await openInMemoryPushDatabase()
+ clock = 1_700_000_000_000
+ devices = new PushDeviceRegistryStore(database, () => clock)
+ })
+
+ afterEach(async () => {
+ await database.close()
+ })
+
+ async function upsertOk(input: PushDeviceUpsert): Promise {
+ const result = await devices.upsert(input)
+ if (!result.ok) throw new Error(`unexpected upsert refusal: ${result.reason}`)
+ return result.registrationId
+ }
+
+ function androidDevice(deviceId: string): PushDeviceUpsert {
+ return {
+ hostFingerprint: OWNER,
+ deviceId,
+ platform: 'android',
+ token: `token-${deviceId}`,
+ filter: FILTER
+ }
+ }
+
+ it('keeps one registration per host and device while replacing the token', async () => {
+ const first = await upsertOk({
+ hostFingerprint: OWNER,
+ deviceId: 'device-1',
+ platform: 'ios',
+ token: 'a'.repeat(64),
+ apnsEnvironment: 'sandbox',
+ filter: FILTER
+ })
+ clock += 1_000
+ const second = await upsertOk({
+ hostFingerprint: OWNER,
+ deviceId: 'device-1',
+ platform: 'ios',
+ token: 'b'.repeat(64),
+ apnsEnvironment: 'production',
+ filter: FILTER
+ })
+ expect(second).toBe(first)
+ const registration = await devices.findById(first)
+ expect(registration).toMatchObject({
+ token: 'b'.repeat(64),
+ apnsEnvironment: 'production',
+ dead: false
+ })
+ expect(await devices.list(OWNER)).toHaveLength(1)
+ })
+
+ it('revives a registration that a re-registered token replaces', async () => {
+ const registrationId = await upsertOk({
+ hostFingerprint: OWNER,
+ deviceId: 'device-1',
+ platform: 'android',
+ token: 'token-one',
+ filter: FILTER
+ })
+ await devices.markDead(registrationId)
+ expect((await devices.findById(registrationId))?.dead).toBe(true)
+ await upsertOk({
+ hostFingerprint: OWNER,
+ deviceId: 'device-1',
+ platform: 'android',
+ token: 'token-two',
+ filter: FILTER
+ })
+ expect(await devices.findById(registrationId)).toMatchObject({
+ token: 'token-two',
+ dead: false
+ })
+ })
+
+ it('lets only the owning host delete a registration', async () => {
+ const registrationId = await upsertOk({
+ hostFingerprint: OWNER,
+ deviceId: 'device-1',
+ platform: 'android',
+ token: 'token-one',
+ filter: FILTER
+ })
+ expect(await devices.deleteOwned(OTHER, registrationId)).toBe(false)
+ expect(await devices.findById(registrationId)).not.toBeNull()
+ expect(await devices.deleteOwned(OWNER, registrationId)).toBe(true)
+ expect(await devices.findById(registrationId)).toBeNull()
+ })
+
+ it('scopes lookups and listings to the owning host', async () => {
+ const owned = await upsertOk({
+ hostFingerprint: OWNER,
+ deviceId: 'device-1',
+ platform: 'android',
+ token: 'token-one',
+ filter: FILTER
+ })
+ const foreign = await upsertOk({
+ hostFingerprint: OTHER,
+ deviceId: 'device-2',
+ platform: 'android',
+ token: 'token-two',
+ filter: FILTER
+ })
+ const found = await devices.findOwned(OWNER, [owned, foreign])
+ expect([...found.keys()]).toEqual([owned])
+ expect(await devices.list(OTHER)).toEqual([
+ { registrationId: foreign, deviceId: 'device-2', platform: 'android', dead: false }
+ ])
+ expect(await devices.findOwned(OWNER, [])).toEqual(new Map())
+ })
+
+ it('refuses a new device once the host reaches its registration cap', async () => {
+ for (let index = 0; index < PUSH_LIMITS.maxDevicesPerHost; index++) {
+ await upsertOk(androidDevice(`device-${index}`))
+ }
+ expect(await devices.upsert(androidDevice('one-too-many'))).toEqual({
+ ok: false,
+ reason: 'too_many_devices'
+ })
+ expect(await devices.list(OWNER)).toHaveLength(PUSH_LIMITS.maxDevicesPerHost)
+ })
+
+ it('still lets a capped host re-register a device it already owns', async () => {
+ for (let index = 0; index < PUSH_LIMITS.maxDevicesPerHost; index++) {
+ await upsertOk(androidDevice(`device-${index}`))
+ }
+ const rotated = await devices.upsert({ ...androidDevice('device-0'), token: 'rotated-token' })
+ expect(rotated.ok).toBe(true)
+ expect(await devices.list(OWNER)).toHaveLength(PUSH_LIMITS.maxDevicesPerHost)
+ })
+
+ it('frees a slot when a registration is deleted', async () => {
+ const first = await upsertOk(androidDevice('device-0'))
+ for (let index = 1; index < PUSH_LIMITS.maxDevicesPerHost; index++) {
+ await upsertOk(androidDevice(`device-${index}`))
+ }
+ expect((await devices.upsert(androidDevice('extra'))).ok).toBe(false)
+ expect(await devices.deleteOwned(OWNER, first)).toBe(true)
+ expect((await devices.upsert(androidDevice('extra'))).ok).toBe(true)
+ })
+
+ it('counts the cap per host, not across the whole table', async () => {
+ for (let index = 0; index < PUSH_LIMITS.maxDevicesPerHost; index++) {
+ await upsertOk(androidDevice(`device-${index}`))
+ }
+ expect((await devices.upsert(androidDevice('extra'))).ok).toBe(false)
+ expect(
+ (await devices.upsert({ ...androidDevice('device-0'), hostFingerprint: OTHER })).ok
+ ).toBe(true)
+ })
+
+ it('never returns more devices than the list response schema accepts', async () => {
+ // Straight past the per-host cap, so only the query LIMIT can bound this.
+ const rows = PUSH_LIMITS.maxDevicesPerListResponse + 5
+ for (let index = 0; index < rows; index++) {
+ await database.query(
+ `INSERT INTO push_devices (registration_id, host_fingerprint, device_id, platform, token,
+ filter_json, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ [`reg-${index}`, OWNER, `device-${index}`, 'android', 'token', '{}', clock + index, clock]
+ )
+ }
+ expect(await devices.list(OWNER)).toHaveLength(PUSH_LIMITS.maxDevicesPerListResponse)
+ })
+
+ it('separates the same device id registered against two hosts', async () => {
+ const first = await upsertOk({
+ hostFingerprint: OWNER,
+ deviceId: 'shared-device',
+ platform: 'ios',
+ token: 'a'.repeat(64),
+ apnsEnvironment: 'sandbox',
+ filter: FILTER
+ })
+ const second = await upsertOk({
+ hostFingerprint: OTHER,
+ deviceId: 'shared-device',
+ platform: 'ios',
+ token: 'c'.repeat(64),
+ apnsEnvironment: 'sandbox',
+ filter: FILTER
+ })
+ expect(first).not.toBe(second)
+ })
+})
diff --git a/cloud/apps/push/src/device-registry-store.ts b/cloud/apps/push/src/device-registry-store.ts
new file mode 100644
index 00000000000..9aac22dd25c
--- /dev/null
+++ b/cloud/apps/push/src/device-registry-store.ts
@@ -0,0 +1,185 @@
+import { randomUUID } from 'node:crypto'
+import {
+ PUSH_LIMITS,
+ type ApnsEnvironment,
+ type PushDeviceSummary,
+ type PushNotificationFilter,
+ type PushPlatform
+} from '@orca-cloud/push-contract'
+import type { PushDatabase, SqlRow } from './push-database.js'
+
+const DEVICE_CAP_LOCK_PREFIX = 'orca-push-device-cap:'
+
+export type PushDeviceRegistration = {
+ registrationId: string
+ hostFingerprint: string
+ deviceId: string
+ platform: PushPlatform
+ token: string
+ apnsEnvironment?: ApnsEnvironment
+ dead: boolean
+}
+
+export type PushDeviceUpsertResult =
+ | { ok: true; registrationId: string }
+ | { ok: false; reason: 'too_many_devices' }
+
+export type PushDeviceUpsert = {
+ hostFingerprint: string
+ deviceId: string
+ platform: PushPlatform
+ token: string
+ apnsEnvironment?: ApnsEnvironment
+ filter: PushNotificationFilter
+}
+
+function toRegistration(row: SqlRow): PushDeviceRegistration {
+ const apnsEnvironment = row.apns_environment
+ return {
+ registrationId: String(row.registration_id),
+ hostFingerprint: String(row.host_fingerprint),
+ deviceId: String(row.device_id),
+ platform: String(row.platform) as PushPlatform,
+ token: String(row.token),
+ ...(apnsEnvironment === null || apnsEnvironment === undefined
+ ? {}
+ : { apnsEnvironment: String(apnsEnvironment) as ApnsEnvironment }),
+ dead: row.dead_at !== null && row.dead_at !== undefined
+ }
+}
+
+export class PushDeviceRegistryStore {
+ constructor(
+ private readonly database: PushDatabase,
+ private readonly now: () => number = Date.now
+ ) {}
+
+ // The registration id is stable for a (host, device) pair so a re-registered
+ // phone keeps the id the desktop already persisted; only the token rotates.
+ async upsert(input: PushDeviceUpsert): Promise {
+ const now = this.now()
+ const filterJson = JSON.stringify(input.filter)
+ return await this.database.transaction(async (transaction) => {
+ // deviceId is caller-chosen, so counting and inserting must not interleave
+ // or a burst of new ids would walk straight past the cap.
+ await transaction.lockQuotaScope(`${DEVICE_CAP_LOCK_PREFIX}${input.hostFingerprint}`)
+ const [existing] = await transaction.query(
+ 'SELECT registration_id FROM push_devices WHERE host_fingerprint = ? AND device_id = ?',
+ [input.hostFingerprint, input.deviceId]
+ )
+ if (existing) {
+ const registrationId = String(existing.registration_id)
+ await transaction.query(
+ `UPDATE push_devices
+ SET platform = ?, token = ?, apns_environment = ?, filter_json = ?,
+ dead_at = NULL, updated_at = ?
+ WHERE registration_id = ?`,
+ [
+ input.platform,
+ input.token,
+ input.apnsEnvironment ?? null,
+ filterJson,
+ now,
+ registrationId
+ ]
+ )
+ return { ok: true, registrationId }
+ }
+ const [countRow] = await transaction.query(
+ 'SELECT COUNT(*) AS devices FROM push_devices WHERE host_fingerprint = ?',
+ [input.hostFingerprint]
+ )
+ if (Number(countRow?.devices ?? 0) >= PUSH_LIMITS.maxDevicesPerHost) {
+ return { ok: false, reason: 'too_many_devices' }
+ }
+ const registrationId = randomUUID()
+ await transaction.query(
+ `INSERT INTO push_devices
+ (registration_id, host_fingerprint, device_id, platform, token, apns_environment,
+ filter_json, dead_at, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`,
+ [
+ registrationId,
+ input.hostFingerprint,
+ input.deviceId,
+ input.platform,
+ input.token,
+ input.apnsEnvironment ?? null,
+ filterJson,
+ now,
+ now
+ ]
+ )
+ return { ok: true, registrationId }
+ })
+ }
+
+ async deleteOwned(hostFingerprint: string, registrationId: string): Promise {
+ const [result] = await this.database.query(
+ 'DELETE FROM push_devices WHERE registration_id = ? AND host_fingerprint = ?',
+ [registrationId, hostFingerprint]
+ )
+ return Number(result?.changes ?? 0) > 0
+ }
+
+ async list(hostFingerprint: string): Promise {
+ const rows = await this.database.query(
+ // Bounded to what PushDeviceListResponseSchema will accept, so an
+ // oversized table degrades to a truncated list instead of a 500.
+ `SELECT registration_id, device_id, platform, dead_at
+ FROM push_devices WHERE host_fingerprint = ? ORDER BY created_at ASC LIMIT ?`,
+ [hostFingerprint, PUSH_LIMITS.maxDevicesPerListResponse]
+ )
+ return rows.map((row) => ({
+ registrationId: String(row.registration_id),
+ deviceId: String(row.device_id),
+ platform: String(row.platform) as PushPlatform,
+ dead: row.dead_at !== null && row.dead_at !== undefined
+ }))
+ }
+
+ async findOwned(
+ hostFingerprint: string,
+ registrationIds: readonly string[]
+ ): Promise
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk)
+- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk)
---
diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md
index e601abc2344..adf966b5053 100644
--- a/docs/readme/README.fr.md
+++ b/docs/readme/README.fr.md
@@ -40,7 +40,7 @@
Surveillez et pilotez vos agents depuis votre téléphone — soyez notifié quand un agent termine, et envoyez des instructions de suivi où que vous soyez.
-[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
+[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.48](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
@@ -235,7 +235,7 @@ yay -S stably-orca-bin
Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votre téléphone.
- **iOS :** [Télécharger sur l'App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [rejoindre TestFlight](https://testflight.apple.com/join/YjeGMQBA)
-- **Android :** [Télécharger l'APK 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk)
+- **Android :** [Télécharger l'APK 0.0.48](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk)
---
diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md
index cce2032a67c..ce5a7ddf07f 100644
--- a/docs/readme/README.ja.md
+++ b/docs/readme/README.ja.md
@@ -36,7 +36,7 @@
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
-[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk)
+- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk)
---
diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md
index 837ecf2133f..81226572e9f 100644
--- a/docs/readme/README.ko.md
+++ b/docs/readme/README.ko.md
@@ -36,7 +36,7 @@
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
-[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.48](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) 또는 [TestFlight 참여](https://testflight.apple.com/join/YjeGMQBA)
-- **Android:** [APK 0.0.47 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk)
+- **Android:** [APK 0.0.48 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk)
---
diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md
index 86d998a4e5f..4f4461607d3 100644
--- a/docs/readme/README.pt.md
+++ b/docs/readme/README.pt.md
@@ -36,7 +36,7 @@
Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
-[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
+[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.48](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
-- **Android:** [Baixar APK 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk)
+- **Android:** [Baixar APK 0.0.48](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk)
---
diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md
index 10f47e20fe6..970628edd32 100644
--- a/docs/readme/README.zh-CN.md
+++ b/docs/readme/README.zh-CN.md
@@ -36,7 +36,7 @@
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
-[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
与桌面应用配对,用手机监控并指挥你的智能体。
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk)
+- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk)
---
diff --git a/docs/reference/headless-linux-server.md b/docs/reference/headless-linux-server.md
index 50a38cf446e..2b452f05fc5 100644
--- a/docs/reference/headless-linux-server.md
+++ b/docs/reference/headless-linux-server.md
@@ -390,6 +390,10 @@ its own `orca`.
`ws://` through an HTTPS-only endpoint.
- Hostnames, IPv4, bracketed IPv6, and raw IPv6 literals are supported. IPv6
still requires an IPv6-reachable listener/network path.
+- Background push notifications to a paired phone do not fire from a headless
+ server: agent-completion detection runs in the desktop renderer, which serve
+ mode never starts, so nothing reaches the push gateway even though the phone
+ registers successfully.
- `xvfb-run` and `dbus-run-session -- xvfb-run` remain valid diagnostic launch
shapes, but neither should be needed when `Xvfb` is installed and no display
is configured. Repeated D-Bus messages without a ready block indicate startup
diff --git a/docs/reference/mobile-push-contract.md b/docs/reference/mobile-push-contract.md
new file mode 100644
index 00000000000..4f6f4d5d30c
--- /dev/null
+++ b/docs/reference/mobile-push-contract.md
@@ -0,0 +1,352 @@
+# Mobile push: contract and build spec
+
+Tracking issue: stablyai/orca#8129. Design page: `/tmp/orca-mobile-push/orca-mobile-push.html`.
+This document is the single contract every lane builds against. Do not deviate without updating it.
+
+## Summary
+
+A small Orca-hosted push gateway (`cloud/apps/push`) holds the APNs key and FCM credentials and sends
+to phones. The desktop host registers each paired phone's native push token with the gateway and asks
+the gateway to push on every mobile notification it already fans out over the socket. The phone dedupes
+by `notificationId#notificationSeq`. No ack gate, no generic mode, no staging gateway, one auth path for
+signed-in and accountless hosts.
+
+## Identities
+
+- **Host public key**: the desktop's existing X25519 E2EE public key (`src/main/runtime/e2ee-keypair.ts`),
+ 32 bytes, base64. The phone already stores it per host as `publicKeyB64`.
+- **hostFingerprint**: `sha256(hostPublicKey)` base64url, first 16 chars. Identical derivation to
+ `deriveRelayHostId` in `src/main/runtime/relay/relay-http-client.ts`. Both desktop and phone can compute it.
+- **deviceId**: the desktop's `DeviceEntry.deviceId` for the paired phone. Opaque UUID.
+- **registrationId**: gateway-assigned opaque id for one (hostFingerprint, deviceId) pair.
+
+## Gateway HTTP API
+
+Base URL: `https://push.onorca.dev` (dev override via env). JSON bodies, `Content-Type: application/json`.
+All schemas are zod, `.strict()`, exported from `cloud/packages/push-contract`.
+
+### Host authentication: challenge, proof, session
+
+The host keypair is X25519 (box), so it cannot sign. Reuse the relay's challenge shape.
+
+`POST /v1/host/challenge`
+```json
+{ "v": 1, "hostPublicKeyB64": "<32 bytes b64>" }
+```
+→ 200
+```json
+{ "challengeId": "", "gatewayEphemeralPublicKeyB64": "<32 b64>", "nonceB64": "<24 b64>",
+ "ciphertextB64": "", "expiresAt": }
+```
+- Gateway generates an ephemeral box keypair per challenge, a 24-byte nonce, and a 32-byte secret.
+- `plaintext = "orca-push-host-challenge/v1\0" || u32be(len(transcript)) || transcript || secret(32)`
+- `ciphertext = nacl.box(plaintext, nonce, hostPublicKey, gatewayEphemeralSecretKey)`
+- Transcript is the relay's length-prefixed field encoding (`field(name, value)` =
+ u32be(len(name)) || name || u32be(len(value)) || value), fields in this exact order:
+ `protocol="orca-push-host-proof/v1"`, `version=0x01`, `gatewayOrigin`, `gatewayEphemeralPublicKey`,
+ `challengeNonce`, `challengeId`, `issuedAt` (u64be ms), `expiresAt` (u64be ms), `hostFingerprint`,
+ `hostPublicKey`.
+- Challenge TTL 10 s, and 10 s is the whole window the gateway honours. The 30 s clock skew tolerance
+ is the host's alone: it validates a timestamp the gateway chose, so it needs the allowance and the
+ gateway does not. A gateway that subtracted the tolerance from its own check would run a 40 s TTL.
+ Store challenge (id, secret hash, host fingerprint, host public key, expiry) in DB so any Cloud Run
+ instance can verify. Expired rows are pruned 30 s late so a slow proof reads as expired rather than
+ as an unknown challenge.
+- Issuing a challenge writes no `push_hosts` row. It is unauthenticated, so a `push_hosts` row would be
+ a free permanent write for any caller. The row is upserted in `POST /v1/host/session` once the proof
+ verifies, from the public key the challenge row carries.
+
+`POST /v1/host/session`
+```json
+{ "v": 1, "challengeId": "", "proofB64": "<32 b64>" }
+```
+- Host opens the box with its secret key, validates every transcript field (same checks as
+ `validateTranscript` in `src/main/runtime/relay/relay-host-proof.ts`, adapted to the push fields),
+ and returns `proof = HMAC-SHA256(secret, "orca-push-host-proof/v1\0ack\0" || transcript)`.
+- Gateway verifies with `timingSafeEqual`, consumes the challenge (single use), and returns
+```json
+{ "sessionToken": "", "expiresAt": , "hostFingerprint": "<16 chars>" }
+```
+- Session TTL 24 h. Stored hashed (sha256) in DB. Bearer on every other call:
+ `Authorization: Bearer `. 401 with `{ "error": "session_expired" }` on expiry; host
+ re-runs the challenge.
+
+### Device registration
+
+`POST /v1/devices` (Bearer)
+```json
+{ "v": 1, "deviceId": "", "platform": "ios" | "android", "token": "",
+ "apnsEnvironment": "sandbox" | "production", // ios only, required for ios
+ "filter": { "sources": ["agent-task-complete", "terminal-bell", "plugin"],
+ "agentStates": ["needs-input", "finished"] } }
+```
+→ 200 `{ "registrationId": "" }`. Upsert keyed by (hostFingerprint, deviceId); a new token
+replaces the old. `deviceId` is caller-chosen, so a host is capped at 64 registrations: the 65th
+distinct `deviceId` → 409 `{ "error": "too_many_devices" }`. Re-registering a `deviceId` the host
+already owns is always accepted, and deleting a registration frees its slot. `GET /v1/devices` is
+bounded at 1024 rows to match its response schema, which the per-host cap keeps well out of reach.
+`filter` is stored but enforced by the host (see desktop); gateway stores it only so a
+host restart can re-read it. iOS tokens are variable-length, hex-encoded byte strings; Android
+tokens are FCM registration strings.
+
+`DELETE /v1/devices/:registrationId` (Bearer) → 204. Only the owning host may delete.
+
+`GET /v1/devices` (Bearer) → `{ "devices": [{ registrationId, deviceId, platform, dead: boolean }] }`.
+
+### Send
+
+`POST /v1/send` (Bearer)
+```json
+{ "v": 1,
+ "registrationIds": ["", "..."],
+ "notification": {
+ "notificationId": "",
+ "notificationSeq": , "notificationEpoch": "",
+ "source": "agent-task-complete" | "terminal-bell" | "plugin",
+ "agentState": "needs-input" | "finished" | null,
+ "title": "", "body": "",
+ "worktreeId": "" } }
+```
+→ 200
+```json
+{ "results": [{ "registrationId": "", "status": "queued" | "dead" | "rate_limited" | "error" }] }
+```
+- `queued` means accepted into the coalescing window. `dead` means the provider reported the token
+ unregistered; the host must drop the registration. Never block the socket fan-out on this call.
+- Quota: 60 sends per hostFingerprint per rolling hour, 200 per registration per rolling day. Over quota
+ → `rate_limited` per result, HTTP 200. Whole request over a hard cap of 20 registrationIds → 400.
+ The cap counts the ids as sent; the gateway then dedupes them, so a repeated id spends quota once,
+ yields one result, and counts once toward `coalescedCount`. `results` may therefore be shorter than
+ `registrationIds`, and callers must match a result by its `registrationId`, never by position.
+- Notification JSON is limited to 3000 UTF-8 bytes to leave provider envelope space; identities
+ are preserved exactly, including long filesystem paths. Oversized payloads fail validation.
+- Gateway retries are deduplicated by host, registration, notification epoch, and sequence in the
+ quota ledger for its 25-hour retention window. Duplicates return `queued` without reserving
+ quota or enqueueing another delivery.
+- Both quota counters are reserved under a per-host lock held for the whole transaction. PostgreSQL
+ reads at READ COMMITTED, so a concurrent count-then-insert would otherwise admit a whole burst.
+
+### Request limits and unauthenticated abuse
+
+- Every POST is capped at 16 KiB by a streaming body limit, not by `Content-Length` alone: a chunked
+ body declares no length. Over the cap → 413 `{ "error": "request_too_large" }`.
+- `POST /v1/host/challenge` and `POST /v1/host/session` are the only unauthenticated routes. They share
+ one token bucket per client IP, 30 requests per minute, refilling continuously. Over the bucket → 429
+ `{ "error": "rate_limited" }`. The client IP is the **last** `x-forwarded-for` hop, not the first:
+ Cloud Run appends the connecting peer, so everything left of that value is caller-supplied and can be
+ a fresh forgery on every request, which would hand a flood a new bucket each time.
+ `ORCA_PUSH_TRUSTED_PROXY_HOPS` (default 0) says how many appenders sit between the platform and the
+ client, so a future load balancer sets it to 1. A header with fewer hops than that depth is not
+ trusted at all. Falls back to `x-real-ip` and then to a single shared bucket. The bucket is per
+ instance and in memory, so the effective cap scales with the instance count; it exists to blunt a
+ flood, not to meter.
+- Every other `/v1` route is capped by a second, wider bucket per client IP, 240 requests per minute,
+ applied **before** the bearer is looked up. A bearer has to be read from the database before it can
+ be refused, and that read takes one of only two pool connections per instance, so without this cap
+ a flood of forged bearers would starve real hosts of the pool while every one of them got a 401.
+- The gateway cannot prove that a host owns the token it registers: any host with a session may
+ register any well-formed token and send text to it, within its own quota. The phone drops such a push
+ in the foreground because the fingerprint resolves to no paired host, and never routes a tap on it,
+ but the OS banner shows while the app is backgrounded. Reaching it needs the victim's native token,
+ which the gateway never returns and which only the phone and its host ever see.
+
+### Coalescing (gateway)
+
+Per registrationId, hold sends for 3 s. If one event arrives, send it as-is. If N>1 arrive, send one
+summary: title `Orca`, body ` agents need attention` (or ` updates` when no needs-input), data
+carries the latest event's fields plus `coalescedCount`. Collapse id for a summary is
+`host:` so a later summary replaces it. The window is held in memory per gateway
+instance, so with more than one instance a burst can produce up to one summary per instance; accepted
+for this release, and the collapse id keeps the phone showing one banner. Transient provider errors
+retry at most three attempts within two minutes, honoring Retry-After and FCM minimum delays. Permanent failures
+are not retried. Unregister/dead-token state is re-read before every attempt. Shutdown stops admission
+and drains admitted requests, pending windows, and active deliveries before closing resources;
+a nine-second hard deadline remains below Cloud Run's termination grace. Delivery remains in memory.
+
+### Provider payloads
+
+APNs (HTTP/2, `api.push.apple.com` or `api.sandbox.push.apple.com` by `apnsEnvironment`; JWT auth
+from key id + team id + `.p8`, token cached and refreshed every 50 min):
+- headers: `apns-topic: com.stably.orca.mobile`, `apns-push-type: alert`, `apns-priority: 10`,
+ `apns-expiration: now+4h`, `apns-collapse-id: >`
+- body: `{"aps":{"alert":{"title","body"},"sound":"default","thread-id":""},
+ "orca":{ hostFingerprint, worktreeId, notificationId, notificationSeq, notificationEpoch, source,
+ agentState, coalescedCount }}`
+- Dead token: 410, or 400 with `BadDeviceToken`/`Unregistered`/`DeviceTokenNotForTopic`.
+
+FCM (V1 `projects/onorca-cloud/messages:send`, bearer from the runtime service account via the GCE
+metadata server or `GOOGLE_APPLICATION_CREDENTIALS` locally):
+- `{"message":{"token","notification":{"title","body"},"android":{"priority":"HIGH","ttl":"14400s",
+ "collapse_key":"","notification":{"channel_id":"orca-desktop","tag":""}},
+ "data":{ all orca fields as strings }}}`
+- Dead token: `UNREGISTERED`, or `INVALID_ARGUMENT` whose message names the token.
+
+### Gateway storage (Postgres in prod, SQLite in tests, same pattern as `cloud/apps/relay/src/database.ts`)
+
+- `push_hosts(host_fingerprint pk, host_public_key, created_at, last_seen_at)`, written only on a
+ verified proof and pruned after 1 h of no contact when no `push_devices` row still names the host.
+ Nothing reads it, and any keypair mints a host for free, so it is not allowed to accumulate.
+- `push_sessions` holds one row per host, enforced by a unique index and transaction lock. Minting a
+ session deletes the host's earlier one, since a desktop holds a single session and only re-proves once it is gone.
+- `push_challenges(challenge_id pk, host_fingerprint, host_public_key, secret_hash, transcript,
+ expires_at, consumed_at)`
+- `push_sessions(token_hash pk, host_fingerprint, expires_at, created_at)`
+- `push_devices(registration_id pk, host_fingerprint, device_id, platform, token, apns_environment,
+ filter_json, dead_at, created_at, updated_at, unique(host_fingerprint, device_id))`
+- `push_send_log(host_fingerprint, registration_id, sent_at)` for quota, pruned after 25 h.
+
+Logging: aggregate counters only. Never log tokens, titles, bodies, or raw fingerprints (log the first
+4 chars of a fingerprint at most).
+
+### Gateway env
+
+`PORT`, `ORCA_PUSH_PUBLIC_URL`, `ORCA_PUSH_DATABASE_URL` (absent → SQLite under `ORCA_PUSH_DATA_DIR`),
+`ORCA_PUSH_APNS_KEY` (PEM text), `ORCA_PUSH_APNS_KEY_ID`, `ORCA_PUSH_APPLE_TEAM_ID`,
+`ORCA_PUSH_APNS_TOPIC` (default `com.stably.orca.mobile`), `ORCA_PUSH_FCM_PROJECT_ID` (default
+`onorca-cloud`), `ORCA_PUSH_COALESCE_MS` (default 3000), `ORCA_PUSH_TRUSTED_PROXY_HOPS` (default 0,
+proxies appending to `x-forwarded-for` after the client).
+Secret Manager names (already exist in `onorca-cloud`): `orca-cloud-push-apns-key`,
+`orca-cloud-push-apns-key-id`, `orca-cloud-push-apple-team-id`. Runtime SA:
+`orca-cloud-push@onorca-cloud.iam.gserviceaccount.com` (already has FCM admin + secret accessor).
+
+## Desktop (`src/main`, `src/shared`)
+
+- Capability `NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY = 'notifications.remote-push.v1'` in
+ `src/shared/protocol-version.ts`, advertised statically.
+- RPC `notifications.registerPush` params `{ platform, token, apnsEnvironment?, filter }` (same shapes
+ as the gateway `POST /v1/devices` minus deviceId, which comes from `ctx.pairedDeviceId`). Returns
+ `{ registered: true, registrationId } | { registered: false, reason: 'gateway_unreachable' |
+ 'gateway_rejected' | 'not_mobile' | 'registration_storage_failed' | 'throttled' }`. A device may
+ register at most 10 times per minute (`throttled` beyond that, its earlier registration untouched):
+ each call is a gateway write plus a synchronous registry write on the main thread, and a paired
+ phone could otherwise loop it. The unregister RPC is not throttled, since with nothing registered it
+ is a lookup and with something registered it can only run once per successful register. The params
+ schema is strict, so a caller-supplied `deviceId` is an error, not a key silently dropped. Persists `pushRegistration:
+ { registrationId, platform, filter, registeredAt }` on `DeviceEntry` in `device-registry.ts` (new
+ optional field, tolerated by old registries). When the gateway accepted the token but the host could
+ not store it — the device left mobile scope mid-call (`not_mobile`) or the registry write threw
+ (`registration_storage_failed`) — the host queues the gateway delete in the unregister outbox rather
+ than leaking a registration nothing will ever push to. Registration, unregister, and outbox deletes
+ are serialized per device; re-registration first settles earlier cleanup. Authentication failure
+ never drops a durable delete. Stale send responses only clear the exact local registration observed,
+ while provider dead-token updates match the token/platform/environment that was sent. Phones must
+ treat any `registered: false` as "retry later", so an unknown reason string is safe to add.
+- RPC `notifications.unregisterPush` params null → `{ unregistered: boolean }`. Removes the field and
+ enqueues a gateway delete in a durable outbox (`src/main/runtime/push/push-unregister-outbox.ts`,
+ modelled on `relay-revoke-outbox.ts`). Unpair/revoke (`revokeMobileDevice`) enqueues the same. The
+ drain re-reads the queue as it goes, so a delete queued mid-drain lands in the same pass, and a pass
+ that leaves retryable items schedules an unref'd backoff retry (30 s, doubling, capped at 10 min)
+ instead of waiting for the next launch.
+- Both RPCs added to `runtime-rpc-mobile-method-allowlist.ts`.
+- Push client `src/main/runtime/push/push-gateway-client.ts`: challenge/proof/session with token cache,
+ register, delete, send. Node `fetch`. Gateway URL from `profile-cloud-auth-config.ts`
+ (`pushGatewayUrl`, default `https://push.onorca.dev`, env override `ORCA_PUSH_GATEWAY_URL`).
+- Host proof answering: new `src/main/runtime/push/push-host-proof.ts`, a copy of the relay's
+ `answerRelayHostChallenge` with the push transcript fields. Shared code with the relay proof is
+ welcome if it stays a pure refactor.
+- Dispatch hook: in `RuntimeMobileNotificationController.dispatch`, after the socket fan-out, call
+ `pushDispatcher.enqueue(eventWithSeq)`. The dispatcher applies each device's `filter`, skips `dismiss`
+ events, maps `agentState` to `needs-input | finished` (blocked/waiting → needs-input, else finished),
+ batches matching registrationIds into `POST /v1/send` requests of at most 20 registrations each (the
+ gateway's per-request cap; extra devices get their own request rather than being dropped), and drops
+ unchanged registrations the gateway reports `dead`. Failure categories are counted without payload
+ values and logged at most once per minute (with a final flush on shutdown). Fire-and-forget with
+ one retry after 2 s per request; never throws into dispatch.
+- Add `agentState` to `MobileNotificationDispatchEvent` and set it in `src/main/ipc/notifications.ts`
+ from `args.agentState`. Fix `buildAgentTaskCompleteNotificationOptions` so `working|running|busy`
+ never yields "finished" (title says "working" and the dispatcher treats it as not-final, i.e. no push).
+- Headless serve: no renderer means no `notifications:dispatch`. Document in
+ `docs/reference/headless-linux-server.md`; do not fix here.
+
+## Mobile (`mobile/`)
+
+- Commit `google-services.json` (from `/tmp/orca-mobile-push/google-services.json`) at `mobile/` and set
+ `"android": { "googleServicesFile": "./google-services.json" }` in `app.json`. Add `"expo-notifications"`
+ to `plugins` so prebuild writes the `aps-environment` entitlement.
+- Token: `Notifications.getDevicePushTokenAsync()`; `data` is the APNs hex or FCM string. iOS
+ `apnsEnvironment`: `__DEV__ ? 'sandbox' : 'production'` (dev-client builds are debug, TestFlight and
+ App Store are release). Listen with `addPushTokenListener` and re-register on change.
+- Settings (`mobile/app/notifications.tsx`): single "Background notifications" switch, default off,
+ hint text exactly: "Get alerts while Orca is closed. Alerts show the same text as on your desktop. That
+ text, your phone's push token, and opaque host and device ids pass through Orca's push service and Apple
+ or Google. Turning this off or unpairing deletes the token." Event controls live in the shared notification-preferences section and apply to both connected and background notifications.
+ Hide the whole section, with copy "Update your desktop app to enable background notifications", when
+ no paired host advertises `notifications.remote-push.v1`.
+- Registration: on switch-on (after OS permission), and on every host reaching `connected` while the
+ switch is on, call `notifications.registerPush` on that host if it advertises the capability. On
+ switch-off call `notifications.unregisterPush` on every connected host and remember to retry on hosts
+ that were offline. On host removal, best-effort unregister before deleting credentials.
+- Receive: `addNotificationReceivedListener` (foreground) checks `data.orca.notificationId` +
+ `notificationSeq` against the host session seen set in `notification-reconnect-catchup.ts`; if seen,
+ suppress via `setNotificationHandler` returning no banner; otherwise show and mark seen. Background and
+ killed: OS shows it.
+- Tap: `data.orca.hostFingerprint` → hostId by computing the same sha256/base64url/16 derivation over each
+ stored host's `publicKeyB64`; then existing `getNotificationNavigationTarget` + `useOpenNotificationRoute`.
+- Reopen: existing replay catch-up runs unchanged. Dismiss events also
+ `dismissNotificationAsync` any presented notification whose `data.orca.notificationId` matches.
+- Old host without the capability: nothing changes.
+
+## Infra (`cloud/infra/terraform`, `.github/workflows`)
+
+- Cloud Run service `orca-cloud-push`, region `us-central1`, project from the environment tfvars, runtime
+ SA `orca-cloud-push@.iam.gserviceaccount.com` (exists in prod; declare and import), the three
+ secrets mounted as env (exist; declare and import), Cloud SQL connector to the shared instance with its
+ own database `orca_push`, min instances 1, max 4, concurrency 80, ingress all, unauthenticated invoke.
+- IAM: `roles/firebasecloudmessaging.admin` and `roles/serviceusage.serviceUsageConsumer` on the runtime
+ SA (exist in prod; declare and import). Secret accessor per secret.
+- Hostname `push.onorca.dev`. The DNS zone lives in the apps root in `stablyai/orca-cloud`; add the
+ Cloud Run domain mapping here and leave a TODO comment naming the record the other repo must add.
+- Workflow `.github/workflows/cloud-push-deploy.yml`: gated on `vars.ORCA_CLOUD_OPERATIONS_ENABLED`,
+ Workload Identity like `cloud-relay-*`, builds the image, deploys with `--no-traffic`, probes the new
+ revision's `/ready` and a validate-only FCM send, then shifts 100% traffic. Uses
+ `.github/actions/cloud-sql-rollout-lease` around the schema step.
+- Add the new root files to `cloud/dev/contracts` and `cloud/dev/fixtures` partitions so
+ `terraform-root-partition.test.mjs` and `Cloud Verify` pass.
+
+## Non-goals for this release
+
+Ack gate, generic-alert mode, staging gateway, iOS Notification Service Extension, Android data-only
+messages, Live Activities, account-based quota tiers, dismissal via silent push.
+
+### Device delivery preferences
+
+The desktop advertises `notifications.delivery-preferences.v1`. Completion detection remains
+active when desktop notifications are off; semantic validity checks still precede delivery.
+IPC publishes `desktopAllowed: false` for terminal events disabled by the desktop master or
+source switch. Desktop focus and native authorization remain desktop-only delivery gates.
+
+`notifications.subscribe` and `notifications.getMissedSince` accept optional
+`includeDesktopSuppressed: true`. Only opted-in callers receive those events, including replay;
+legacy callers keep the old filtered stream. A new phone against an older host can narrow the
+available events but cannot recover events that host never published.
+
+The phone defaults to following each host. `filter.followDesktop` is optional: absent retains
+legacy desktop gating; explicit false permits independent event choices. The desktop persists
+it with the paired registration and evaluates it for every send, so desktop preference changes
+work while the phone is disconnected. This flag is host-local and is not sent to the gateway.
+The phone uses the same shared event predicate for socket/replay delivery as the push dispatcher.
+Optional `emittedAt` carries the event time for per-device five-second burst suppression after
+source filtering. Desktop eligibility, source, and agent state use separate upstream cooldown
+buckets so filtered events cannot suppress the next eligible event. Legacy RPC callers retain
+workspace-wide burst suppression on the host.
+
+`filter.sound` is also host-local. False groups that device's requests separately and adds
+optional `notification.sound: false` to gateway sends. The gateway omits APNs `aps.sound` and
+uses Android's `orca-desktop-silent` channel. Missing sound preserves existing audible delivery.
+Deploy the updated gateway before distributing hosts that send the optional sound field: older
+gateways strictly reject unknown notification fields. No token or database migration is needed.
+
+The phone's master switch disables background registration as well as local scheduling. Sound
+and viewing preferences belong to the receiving phone. The phone suppresses a banner for its
+currently viewed host/workspace only while active; it never assumes desktop focus means the
+phone is viewing that workspace. Changes to an offline host's persisted filter take effect on
+reconnection. No live APNs/FCM delivery is implied by simulator notification injection.
+
+For a phone registered for background push, socket notification delivery waits while the app is
+inactive. On foreground, it checks the native push tray before scheduling a local fallback, so
+a still-connected background socket cannot duplicate APNs/FCM delivery. Unsubscribing cancels
+the wait without claiming delivery. Hosts without push registration keep local delivery.
+
+Native notification readers accept Expo's iOS `request.trigger.payload` as well as
+`request.content.data`. APNs custom fields can exist only in the former; foreground deduplication,
+tray replay suppression, dismissal, and tap routing all use the same reader.
diff --git a/docs/reference/windows-process-enumeration.md b/docs/reference/windows-process-enumeration.md
index 34afb56c8e6..0f7f17bd433 100644
--- a/docs/reference/windows-process-enumeration.md
+++ b/docs/reference/windows-process-enumeration.md
@@ -344,7 +344,7 @@ on any other OS keeps using the scan.
## Why the package is patched
-`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries four hunks.
+`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries five changes.
1. **Spectre mitigation.** The upstream `binding.gyp` requires Spectre-mitigated
libraries, which Orca's Windows build agents do not install. `node-pty` is
@@ -360,6 +360,32 @@ on any other OS keeps using the scan.
`node_addon_api.gyp` resolves outside the repo and hourly Windows builds
die at configure. `node-pty` is patched the same way for the same reason.
4. **No PEB reads, no `PROCESS_VM_READ`.** See below.
+5. **The `CreationTime` flag (4).** Upstream exposes no process start time, and
+ `isWindowsProcessStartTimeAvailable()` gates structured Claude and Codex
+ chat on it, so without this change win32 silently fell back to the legacy
+ transcript path. `GetProcessCreationTime` opens
+ `PROCESS_QUERY_LIMITED_INFORMATION` and converts `GetProcessTimes`' FILETIME
+ to Unix ms; a process that denies the handle is emitted with the field
+ absent, never zero, because callers must be able to tell "cannot identify"
+ from a timestamp.
+5. **`supportedProcessDataFlags`.** `addon.cc` exports the flag bits the
+ compiled binary understands, and `lib/index.js` re-exports it.
+
+ Why a fifth hunk and not just the enum: unlike `node-pty`, this package
+ publishes a prebuilt `.node` at the same `build/Release/` path node-gyp
+ writes to. pnpm patches the source tree and leaves that prebuilt alone, so a
+ host can hold a patched `lib/index.js` — `ProcessDataFlag.CreationTime` and
+ all — over a binary that ignores flag 4. CI produced exactly that: the gate
+ read available and every row came back without `creationTimeMs`. Neither a
+ load check nor a path check can see the difference, so the binary has to say
+ so itself.
+
+ Two readers depend on it. `isWindowsProcessStartTimeAvailable()` returns
+ false unless this bit is set, because claiming otherwise leaves
+ `captureWindowsDescendantSnapshot` returning null forever while structured
+ chat believes it has a reaper. And `windows-process-tree-creation-time.cjs`
+ asserts it during install, which is what forces a from-source rebuild —
+ the same role `node-pty-job-ownership.cjs` plays for node-pty's job exports.
The typings claim `commandLine` is truncated at 512 characters. Measured, it is
not: the longest observed on a real host was 26,059.
@@ -494,10 +520,10 @@ already has, which is why the addon is checked again at load.
## What the snapshot does not provide
-`CreationDate` (process start time) has no equivalent. Anything using a start
-time to prove a PID has not been recycled — daemon identity, managed-hook
-ownership, and CPU accounting in the memory collector — still reads it through
-its own query. Those callers are not migrated.
+`CreationDate` (process start time) now has an equivalent — `creationTimeMs`,
+above — but only inside this module. Daemon identity, managed-hook ownership and
+CPU accounting in the memory collector still read a start time through their own
+queries; those callers are not migrated.
Committed private bytes have no equivalent either, and the one memory value the
addon can produce is unusable for the sizes Orca now sees: `process.cc` stores
@@ -509,10 +535,12 @@ counters in the same pass. Migrating it to the native table would cost both, and
it is why this module no longer sets the `Memory` flag at all: the field had no
reader, and asking for it opened a handle per process on every snapshot.
-Start time is a proxy for identity, not identity. The durable answer for the
-process trees Orca itself spawns is an inherited handle: a job object names the
-tree Orca created, so no start-time comparison is needed. Those readers should
-be resolved that way rather than by adding a start time to this module.
+Start time is a proxy for identity, not identity. For the process trees Orca
+itself spawns the durable answer is still an inherited handle: a job object
+names the tree Orca created, so no start-time comparison is needed. The
+`creationTimeMs` this snapshot now carries is for the trees Orca did **not**
+create the handle for — a recovered agent session, a descendant walked out of
+the table — where a bare PID is all there is to re-identify.
Do not adopt `getProcessCpuUsage()` from the package. It takes both CPU samples
inside one call with a blocking `Sleep(1000)` in the middle, which would hold a
diff --git a/docs/site/content/docs/mobile.mdx b/docs/site/content/docs/mobile.mdx
index 13365eac3ae..d0967c1d8c5 100644
--- a/docs/site/content/docs/mobile.mdx
+++ b/docs/site/content/docs/mobile.mdx
@@ -11,7 +11,7 @@ The Orca mobile companion is an iOS/Android app that pairs with your desktop Orc
The mobile companion is in beta. Install iOS from the [App
Store](https://apps.apple.com/us/app/orca-ide/id6766130217), join the [TestFlight preview
channel](https://testflight.apple.com/join/YjeGMQBA), or install Android from the [current APK
- 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk).
+ 0.0.48](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk).
## What you can do from mobile
@@ -31,7 +31,7 @@ The Orca mobile companion is an iOS/Android app that pairs with your desktop Orc
- Create a workspace from mobile with the same Smart source modes as desktop: Smart, GitHub, Linear, GitLab, Branch, and Name. With **multiple connected desktops**, **New Workspace** asks which host should create it first (one connected host skips the picker).
- Open a host card's **⋯** menu for **Edit**, **Connect**, **Remove**, and related actions (long-press still works as a shortcut).
- Edit a saved host's display name or connection address without re-pairing (for example when the desktop moves between home LAN and Tailscale).
-- Get push notifications when an agent finishes, mirroring [desktop notifications](/docs/notifications).
+- Get push notifications when an agent finishes or needs input, mirroring [desktop notifications](/docs/notifications). Turn on **Background notifications** in the phone's Notifications settings to keep receiving them while Orca is closed; see [Notifications](/docs/notifications#background-notifications-on-your-phone) for what that sends and where.
The mobile app is intentionally not a full editor — it's a remote control for the desktop you already have running.
diff --git a/docs/site/content/docs/model/quick-open.mdx b/docs/site/content/docs/model/quick-open.mdx
index 7e74ceeb4ea..e47222f2e82 100644
--- a/docs/site/content/docs/model/quick-open.mdx
+++ b/docs/site/content/docs/model/quick-open.mdx
@@ -19,9 +19,9 @@ Type a web search instead of a path or URL to open it in the worktree browser wi
## Worktree Jump Palette (Cmd-J)
-Jump across every worktree and every tab in one search. The placeholder in the empty input reads _repo/worktree_ — type either half and Orca filters accordingly. Once you start typing, search includes non-archived worktrees even if they are hidden by the sidebar's current filters. Slack-style emoji shortcodes (`:rocket:`) use the same suggestion popover as workspace naming.
+Jump across worktrees and tabs in one search. The palette opens with the sidebar's current host and project scope, including individual repository selections. The placeholder in the empty input reads _repo/worktree_ — type either half and Orca filters accordingly. Typing can still find non-archived worktrees hidden by the sidebar's other visibility toggles, but it keeps that host and repository scope. Slack-style emoji shortcodes (`:rocket:`) use the same suggestion popover as workspace naming.
-Press **Tab** in the palette for a host and project filter menu. Selected hosts and projects narrow the result set and show as chips you can remove one at a time; closing the palette clears the filter so the next open is unscoped.
+Press **Tab** in the palette for a host and project filter menu. Project choices are repository-granular. Selected hosts and repositories narrow the result set and show as chips you can remove one at a time. Changes are temporary: closing the palette discards them, and the next open reseeds the filter from the sidebar.
Results include:
diff --git a/docs/site/content/docs/model/worktrees.mdx b/docs/site/content/docs/model/worktrees.mdx
index 39fbbda3b59..f71d21c2ca4 100644
--- a/docs/site/content/docs/model/worktrees.mdx
+++ b/docs/site/content/docs/model/worktrees.mdx
@@ -102,7 +102,7 @@ The sidebar header filter menu groups host and project scope under a shared **Sh
- **Other-client** workspaces — **Hide other-client workspaces** appears when a shared [Remote Orca Server](/docs/remote-servers) has workspaces created from another paired client; turn it on to keep this device's list to workspaces you created here. Empty `Cmd-J` recents and numeric shortcuts follow the same filter; typing a query still finds hidden rows.
- **Detached HEAD** workspaces — checkouts sitting on a commit rather than a branch
-Active filter count shows on the filter control; **Clear** resets only the filters that are on. Text search and [Worktree Jump Palette](/docs/model/quick-open) (`Cmd-J`) still reach workspaces hidden only by these filters once you type a query — the jump palette also has its own host/project filters (**Tab**).
+Active filter count shows on the filter control; **Clear** resets only the filters that are on. Text search and [Worktree Jump Palette](/docs/model/quick-open) (`Cmd-J`) still reach workspaces hidden only by the hide toggles once you type a query. Cmd-J keeps the sidebar's host and project scope when it opens; press **Tab** to adjust its temporary host and individual-repository filters.
When you add a parent folder that contains multiple Git repos, Orca can import the selected repos separately or group them under one project group.
diff --git a/docs/site/content/docs/notifications.mdx b/docs/site/content/docs/notifications.mdx
index 8d5e02866f7..aeea1c65d6d 100644
--- a/docs/site/content/docs/notifications.mdx
+++ b/docs/site/content/docs/notifications.mdx
@@ -29,3 +29,33 @@ Pick a custom desktop notification sound per category under [Settings → Notifi
Supported formats: MP3, WAV, OGG, M4A, AAC, FLAC. One file applies to all delivered desktop notifications.
When you use a custom sound, set its playback volume from the same settings pane.
+
+## Background notifications on your phone
+
+The Orca mobile app shows an agent-finished or needs-input alert while it is open and connected to your desktop. To keep receiving them while the app is in the background or closed, turn on **Background notifications** in the phone's Notifications settings. It is off by default.
+
+When it is on, your desktop sends each alert to Orca's push service, which delivers it through Apple or Google to your phone. The alert shows the same title and text as the desktop notification. What leaves your computer is that text, your phone's push token, and opaque host and device ids. Orca's push service keeps the text only long enough to send it and never writes it to storage. Apple and Google can read it in transit, as they can for any app's notifications. The service is open source in the Orca repository under `cloud/apps/push`.
+
+Turning the switch off, or unpairing the phone from the desktop, deletes the token from the push service. The **Enable notifications** switch turns off both connected alerts and background push. Removing a host from the phone while that desktop is offline may leave background alerts arriving from it until the desktop is unpaired or the switch is turned off on the phone.
+
+Background notifications need a paired desktop that has been updated to advertise the feature; the phone hides the switch otherwise. They do not fire from a headless `orca serve` host, because agent-completion detection runs in the desktop app. On Android they need Google Play services, so de-Googled phones keep the in-app behaviour only.
+
+## Notification preferences on your phone
+
+**Use desktop settings** is on by default. Each paired desktop's notification master switch,
+**Agent Task Complete**, and **Terminal Bell** switches determine which terminal events reach
+this phone. Desktop focus and desktop OS permissions do not suppress phone alerts.
+
+Turn off **Use desktop settings** to choose **Task finished**, **Needs input**, **Terminal bell**,
+and **Plugin notifications** independently on your phone. These event filters apply to both
+connected notifications (including reconnect catch-up) and background push. Older desktops
+still filter events before forwarding them; update the desktop to enable independent delivery.
+Previously customized background agent-state filters are preserved as independent preferences.
+
+A terminal bell is a program's attention signal, not proof that an agent finished. Disable
+**Terminal bell** on your phone if a CLI repeatedly rings while it is working.
+
+**Notification sound** and **Suppress while viewing workspace** are local to the phone.
+Viewing suppression applies only while the phone is open on that host's workspace. Background
+notifications can still arrive while the phone is closed. Phone sound choices do not sync custom
+desktop audio files. Preference changes reach disconnected desktops when they reconnect.
diff --git a/mobile/app.config.js b/mobile/app.config.js
new file mode 100644
index 00000000000..4927fa3c956
--- /dev/null
+++ b/mobile/app.config.js
@@ -0,0 +1,19 @@
+// Why this file exists: a bare "expo-notifications" plugin entry writes
+// `aps-environment: development` into the iOS entitlements, while push-token.ts
+// reports `production` for every non-__DEV__ build. A TestFlight or App Store build
+// would then register a production APNs token against a sandbox entitlement, and the
+// gateway's pushes would be accepted by Apple and delivered nowhere. Deriving the
+// mode from an env var the release workflow sets makes the two agree by construction
+// instead of relying on the export step to rewrite the entitlement.
+//
+// app.json stays the source for everything else: Expo reads it first and hands it to
+// this function, so the fastlane version/buildNumber rewrite still flows through.
+const APS_ENVIRONMENT =
+ process.env.ORCA_IOS_APS_ENVIRONMENT === 'production' ? 'production' : 'development'
+
+module.exports = ({ config }) => ({
+ ...config,
+ plugins: (config.plugins ?? []).map((plugin) =>
+ plugin === 'expo-notifications' ? ['expo-notifications', { mode: APS_ENVIRONMENT }] : plugin
+ )
+})
diff --git a/mobile/app.json b/mobile/app.json
index fc36687d74f..6121923f775 100644
--- a/mobile/app.json
+++ b/mobile/app.json
@@ -75,10 +75,12 @@
"allowBackup": false,
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
"package": "com.stably.orca.mobile",
- "versionCode": 16
+ "versionCode": 16,
+ "googleServicesFile": "./google-services.json"
},
"plugins": [
"expo-router",
+ "expo-notifications",
"./plugins/android-respect-rotation-lock.js",
[
"expo-splash-screen",
diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx
index 9080cdedcf9..661a18359a5 100644
--- a/mobile/app/_layout.tsx
+++ b/mobile/app/_layout.tsx
@@ -1,6 +1,9 @@
+import { readNativeNotificationData } from '../src/notifications/native-notification-data'
+import { loadNotificationDeliveryPreferences } from '../src/notifications/notification-delivery-preferences'
+import { setNotificationViewingWorkspace } from '../src/notifications/notification-viewing-policy'
import { useCallback, useEffect, useRef } from 'react'
import { View, StyleSheet } from 'react-native'
-import { Stack, useRouter } from 'expo-router'
+import { Stack, useRouter, useGlobalSearchParams, usePathname } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
import * as SplashScreen from 'expo-splash-screen'
import * as Notifications from 'expo-notifications'
@@ -10,6 +13,13 @@ import { OrcaLogo } from '../src/components/OrcaLogo'
import { RpcClientProvider } from '../src/transport/client-context'
import { getNotificationNavigationTarget } from '../src/notifications/notification-routing'
import { useOpenNotificationRoute } from '../src/notifications/use-open-notification-route'
+import {
+ isRemotePushTrigger,
+ pushNotificationRouteData,
+ shouldSuppressForegroundPush
+} from '../src/notifications/push-receive'
+import { startPushTokenSync } from '../src/notifications/push-registration'
+import { ensureDesktopNotificationChannel } from '../src/notifications/desktop-notification-channel'
import { loadHostCatalog } from '../src/transport/host-store'
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery'
@@ -19,22 +29,44 @@ import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing
// between the native splash and the first React paint.
SplashScreen.preventAutoHideAsync()
+// Why at boot and not only on subscribe: the gateway's FCM payload targets the
+// 'orca-desktop' channel, and a background push can land before any socket has
+// connected. Android drops a notification whose channel does not exist yet.
+ensureDesktopNotificationChannel()
+
// Why: without this, expo-notifications silently drops notifications when
// the app is in the foreground. Setting all three to true makes iOS/Android
// display the banner, play the sound, and show the badge even while the
// app is active. This runs once at module load time before any notification
// is scheduled.
Notifications.setNotificationHandler({
- handleNotification: async () => ({
- shouldShowBanner: true,
- shouldShowList: true,
- shouldPlaySound: true,
- shouldSetBadge: false
- })
+ handleNotification: async (notification) => {
+ // Why the check: a gateway push can arrive for an event the socket already
+ // delivered, and only the handler can stop the OS drawing a second banner.
+ const suppressed = await shouldSuppressForegroundPush(
+ readNativeNotificationData(notification.request)
+ ).catch(() => false)
+ return {
+ shouldShowBanner: !suppressed,
+ shouldShowList: !suppressed,
+ shouldPlaySound: !suppressed && (await loadNotificationDeliveryPreferences()).sound,
+ shouldSetBadge: false
+ }
+ }
})
export default function RootLayout() {
const router = useRouter()
+ const pathname = usePathname()
+ const { hostId, worktreeId } = useGlobalSearchParams<{ hostId?: string; worktreeId?: string }>()
+ useEffect(() => {
+ setNotificationViewingWorkspace(
+ pathname.includes('/session/') && typeof hostId === 'string' && typeof worktreeId === 'string'
+ ? { hostId, worktreeId }
+ : null
+ )
+ return () => setNotificationViewingWorkspace(null)
+ }, [pathname, hostId, worktreeId])
const openNotificationRoute = useOpenNotificationRoute()
const handledNotificationIdsRef = useRef>(new Set())
@@ -44,6 +76,10 @@ export default function RootLayout() {
void recoverMobileRelayPairing()
}, [])
+ // Why: a rolled APNs/FCM token stops delivering silently, so every paired host
+ // has to be re-registered with the new one as soon as the provider hands it over.
+ useEffect(() => startPushTokenSync(), [])
+
// Why: route `orca://pair?...` deep links to the confirm screen so
// the same pairing flow runs whether the link arrived via QR scan,
// paste, AirDrop, Messages, or `xcrun simctl openurl`. getInitialURL
@@ -94,9 +130,18 @@ export default function RootLayout() {
}
}
- async function getNavigationTarget(data: unknown) {
+ async function getNavigationTarget(notification: Notifications.Notification) {
const hosts = await loadHostCatalog().catch(() => null)
- return getNotificationNavigationTarget(data, {
+ const data = readNativeNotificationData(notification.request)
+ // A gateway push names its host by key fingerprint, not by this device's hostId.
+ // With no catalog to resolve against, such a push stays unrouted instead of
+ // falling back to whatever hostId its raw data carries.
+ const routeData = pushNotificationRouteData(
+ data,
+ hosts ?? [],
+ isRemotePushTrigger(notification.request.trigger)
+ )
+ return getNotificationNavigationTarget(routeData, {
knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined,
credentialStatusByHostId: hosts
? new Map(hosts.map((host) => [host.id, host.credentialStatus]))
@@ -124,7 +169,7 @@ export default function RootLayout() {
}
}
- const target = await getNavigationTarget(response.notification.request.content.data)
+ const target = await getNavigationTarget(response.notification)
clearLastNotificationResponse()
if (disposed) {
return
diff --git a/mobile/app/notifications.tsx b/mobile/app/notifications.tsx
index d9696251a94..db1b94238cc 100644
--- a/mobile/app/notifications.tsx
+++ b/mobile/app/notifications.tsx
@@ -1,13 +1,36 @@
+import { NotificationDeliverySection } from '../src/notifications/NotificationDeliverySection'
+import {
+ DEFAULT_NOTIFICATION_DELIVERY,
+ loadNotificationDeliveryPreferences,
+ type NotificationDeliveryPreferences
+} from '../src/notifications/notification-delivery-preferences'
import { useState, useCallback, useEffect } from 'react'
-import { AppState, Linking, View, Text, StyleSheet, Pressable, Switch } from 'react-native'
+import {
+ AppState,
+ Linking,
+ View,
+ Text,
+ StyleSheet,
+ Pressable,
+ Switch,
+ ScrollView,
+ Alert
+} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter, useFocusEffect } from 'expo-router'
import { ChevronLeft } from 'lucide-react-native'
import { colors, spacing, typography } from '../src/theme/mobile-theme'
import {
loadPushNotificationsEnabled,
+ loadRemotePushEnabled,
savePushNotificationsEnabled
} from '../src/storage/preferences'
+import { BackgroundNotificationsSection } from '../src/notifications/BackgroundNotificationsSection'
+import {
+ setNotificationDeliveryPreferences,
+ setRemotePushEnabled
+} from '../src/notifications/push-registration'
+import { useRemotePushCapableHosts } from '../src/notifications/use-remote-push-capable-hosts'
import {
ensureNotificationPermissions,
getNotificationPermissionState,
@@ -26,14 +49,22 @@ export default function NotificationsScreen() {
const insets = useSafeAreaInsets()
const [pushEnabled, setPushEnabled] = useState(false)
const [permissionState, setPermissionState] = useState(DEFAULT_PERMISSION_STATE)
+ const [backgroundEnabled, setBackgroundEnabled] = useState(false)
+ const [delivery, setDelivery] = useState(DEFAULT_NOTIFICATION_DELIVERY)
+ const [saving, setSaving] = useState(false)
+ const remotePushSupport = useRemotePushCapableHosts()
const refreshSettings = useCallback(async () => {
- const [enabled, permission] = await Promise.all([
+ const [enabled, permission, background, states] = await Promise.all([
loadPushNotificationsEnabled(),
- getNotificationPermissionState()
+ getNotificationPermissionState(),
+ loadRemotePushEnabled(),
+ loadNotificationDeliveryPreferences()
])
setPushEnabled(enabled)
setPermissionState(permission)
+ setBackgroundEnabled(background)
+ setDelivery(states)
}, [])
useFocusEffect(
@@ -59,11 +90,45 @@ export default function NotificationsScreen() {
if (!granted) {
setPushEnabled(false)
await savePushNotificationsEnabled(false)
+ await setRemotePushEnabled(false)
+ setBackgroundEnabled(false)
return
}
}
setPushEnabled(value)
await savePushNotificationsEnabled(value)
+ if (!value) {
+ await setRemotePushEnabled(false)
+ setBackgroundEnabled(false)
+ }
+ }
+
+ const toggleBackground = async (value: boolean) => {
+ if (value) {
+ const granted = await ensureNotificationPermissions()
+ setPermissionState(await getNotificationPermissionState())
+ if (!granted) {
+ return
+ }
+ }
+ if (value) {
+ await savePushNotificationsEnabled(true)
+ setPushEnabled(true)
+ }
+ setBackgroundEnabled(value)
+ await setRemotePushEnabled(value)
+ }
+
+ const changeDelivery = async (value: NotificationDeliveryPreferences) => {
+ setSaving(true)
+ try {
+ await setNotificationDeliveryPreferences(value)
+ setDelivery(value)
+ } catch {
+ Alert.alert('Could not save notification settings', 'Please try again.')
+ } finally {
+ setSaving(false)
+ }
}
const switchEnabled = pushEnabled && permissionState.granted
@@ -73,7 +138,13 @@ export default function NotificationsScreen() {
: 'Get notified on this device when an agent needs your input or finishes a task.'
return (
-
+ router.back()}>
@@ -83,8 +154,9 @@ export default function NotificationsScreen() {
- Agent notifications
+ Enable notifications void togglePush(v)}
@@ -105,7 +177,19 @@ export default function NotificationsScreen() {
)}
-
+
+ void changeDelivery(value)}
+ />
+ void toggleBackground(value)}
+ />
+
)
}
diff --git a/mobile/google-services.json b/mobile/google-services.json
new file mode 100644
index 00000000000..4120a97dafc
--- /dev/null
+++ b/mobile/google-services.json
@@ -0,0 +1,39 @@
+{
+ "project_info": {
+ "project_number": "120364513935",
+ "project_id": "onorca-cloud",
+ "storage_bucket": "onorca-cloud.firebasestorage.app"
+ },
+ "client": [
+ {
+ "client_info": {
+ "mobilesdk_app_id": "1:120364513935:android:1d951dc430aeb9bc664efa",
+ "android_client_info": {
+ "package_name": "com.stably.orca.mobile"
+ }
+ },
+ "oauth_client": [
+ {
+ "client_id": "120364513935-evfa8502bp5r9hn7afhd9i03oibs8223.apps.googleusercontent.com",
+ "client_type": 3
+ }
+ ],
+ "api_key": [
+ {
+ "current_key": "AIzaSyBmT_w0OUQSiVfxblx-F0qlRvGkBBkTNQU"
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": [
+ {
+ "client_id": "120364513935-evfa8502bp5r9hn7afhd9i03oibs8223.apps.googleusercontent.com",
+ "client_type": 3
+ }
+ ]
+ }
+ }
+ }
+ ],
+ "configuration_version": "1"
+}
diff --git a/mobile/src/home/use-mobile-home-host-connections.ts b/mobile/src/home/use-mobile-home-host-connections.ts
index 989583f11ab..9cf094ee240 100644
--- a/mobile/src/home/use-mobile-home-host-connections.ts
+++ b/mobile/src/home/use-mobile-home-host-connections.ts
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { decodeAccountsSnapshot } from '../components/AccountUsage'
import { subscribeToDesktopNotifications } from '../notifications/mobile-notifications'
+import { attachPushRegistration } from '../notifications/push-registration'
import { usePrimeHosts } from '../transport/client-context'
import { createHostConnectRefetchGate } from '../transport/host-connect-refetch-gate'
import { selectHomeAutoConnectHostIds } from '../transport/home-host-auto-connect'
@@ -37,11 +38,15 @@ function wireMobileHomeHostSubscriptions(
): () => void {
let unsubscribeNotifications: (() => void) | null = null
let unsubscribeAccounts: (() => void) | null = null
+ let detachPushRegistration: (() => void) | null = null
const refetchGate = createHostConnectRefetchGate()
const wireState = (state: ConnectionState): void => {
const reconnected = refetchGate.observe(state)
if (state === 'connected') {
unsubscribeNotifications ??= subscribeToDesktopNotifications(entry.client, entry.hostId)
+ // Why here: this is the one place a host is known to be authenticated, which is
+ // what registerPush needs; it no-ops on hosts without the push capability.
+ detachPushRegistration ??= attachPushRegistration(entry.hostId, entry.client)
unsubscribeAccounts ??= entry.client.subscribe('accounts.subscribe', null, (payload) => {
if (!payload || typeof payload !== 'object') {
return
@@ -78,6 +83,8 @@ function wireMobileHomeHostSubscriptions(
unsubscribeNotifications = null
unsubscribeAccounts?.()
unsubscribeAccounts = null
+ detachPushRegistration?.()
+ detachPushRegistration = null
}
wireState(entry.state)
const unsubscribeState = entry.client.onStateChange(wireState)
@@ -85,6 +92,7 @@ function wireMobileHomeHostSubscriptions(
unsubscribeState()
unsubscribeNotifications?.()
unsubscribeAccounts?.()
+ detachPushRegistration?.()
}
}
diff --git a/mobile/src/notifications/BackgroundNotificationsSection.test.tsx b/mobile/src/notifications/BackgroundNotificationsSection.test.tsx
new file mode 100644
index 00000000000..ced4ec7f210
--- /dev/null
+++ b/mobile/src/notifications/BackgroundNotificationsSection.test.tsx
@@ -0,0 +1,71 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+ BACKGROUND_NOTIFICATIONS_HINT,
+ BACKGROUND_NOTIFICATIONS_UNSUPPORTED,
+ BackgroundNotificationsSection,
+ type BackgroundNotificationsSectionProps
+} from './BackgroundNotificationsSection'
+
+vi.mock('react-native', () => ({
+ AppState: { currentState: 'background' },
+ StyleSheet: { create: (styles: T) => styles },
+ Switch: 'Switch',
+ Text: 'Text',
+ View: 'View'
+}))
+
+describe('BackgroundNotificationsSection', () => {
+ let renderer: ReactTestRenderer | null = null
+
+ afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+ })
+
+ function render(overrides: Partial = {}) {
+ act(() => {
+ renderer = create(
+ createElement(BackgroundNotificationsSection, {
+ supported: true,
+ resolved: true,
+ enabled: true,
+ onToggleEnabled: () => {},
+ ...overrides
+ })
+ )
+ })
+ return renderer!
+ }
+
+ function textOf(tree: ReactTestRenderer): string[] {
+ return tree.root
+ .findAllByType('Text' as never)
+ .map((node) => node.props.children)
+ .filter((child): child is string => typeof child === 'string')
+ }
+
+ it('shows the switch, the disclosure without a second set of event filters', () => {
+ const texts = textOf(render())
+
+ expect(texts).toEqual(['Background notifications', BACKGROUND_NOTIFICATIONS_HINT])
+ })
+
+ it('states verbatim which parties see the alert text and the push token', () => {
+ expect(BACKGROUND_NOTIFICATIONS_HINT).toBe(
+ "Get alerts while Orca is closed. Alerts show the same text as on your desktop. That text, your phone's push token, and opaque host and device ids pass through Orca's push service and Apple or Google. Turning this off or unpairing deletes the token."
+ )
+ })
+
+ it('replaces the whole section when no paired host advertises remote push', () => {
+ const tree = render({ supported: false })
+
+ expect(textOf(tree)).toEqual([BACKGROUND_NOTIFICATIONS_UNSUPPORTED])
+ expect(tree.root.findAllByType('Switch' as never)).toHaveLength(0)
+ })
+
+ it('renders nothing while the paired hosts are still being probed', () => {
+ expect(render({ supported: false, resolved: false }).toJSON()).toBeNull()
+ })
+})
diff --git a/mobile/src/notifications/BackgroundNotificationsSection.tsx b/mobile/src/notifications/BackgroundNotificationsSection.tsx
new file mode 100644
index 00000000000..00f6e86f3c8
--- /dev/null
+++ b/mobile/src/notifications/BackgroundNotificationsSection.tsx
@@ -0,0 +1,94 @@
+import { StyleSheet, Switch, Text, View } from 'react-native'
+import { colors, spacing, typography } from '../theme/mobile-theme'
+
+// Verbatim from the push contract: it is the disclosure for handing a native push
+// token to Orca's gateway and to Apple or Google, so the wording is not ours to edit.
+export const BACKGROUND_NOTIFICATIONS_HINT =
+ "Get alerts while Orca is closed. Alerts show the same text as on your desktop. That text, your phone's push token, and opaque host and device ids pass through Orca's push service and Apple or Google. Turning this off or unpairing deletes the token."
+
+export const BACKGROUND_NOTIFICATIONS_UNSUPPORTED =
+ 'Update your desktop app to enable background notifications'
+
+export type BackgroundNotificationsSectionProps = {
+ /** True once some paired host advertised `notifications.remote-push.v1`. */
+ supported: boolean
+ /** False while every paired host is still being probed; renders nothing rather
+ * than telling someone to update a desktop that may well be current. */
+ resolved: boolean
+ enabled: boolean
+ onToggleEnabled: (value: boolean) => void
+}
+
+export function BackgroundNotificationsSection({
+ supported,
+ resolved,
+ enabled,
+ onToggleEnabled
+}: BackgroundNotificationsSectionProps) {
+ if (!supported) {
+ return resolved ? (
+
+ {BACKGROUND_NOTIFICATIONS_UNSUPPORTED}
+
+ ) : null
+ }
+
+ return (
+
+
+ Background notifications
+
+
+ {BACKGROUND_NOTIFICATIONS_HINT}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ section: {
+ backgroundColor: colors.bgPanel,
+ borderRadius: 12,
+ overflow: 'hidden',
+ marginTop: spacing.md
+ },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm + 2,
+ paddingVertical: spacing.md,
+ paddingHorizontal: spacing.md + 2
+ },
+ subRow: {
+ paddingVertical: spacing.sm,
+ paddingLeft: spacing.lg + spacing.xs
+ },
+ rowLabel: {
+ flex: 1,
+ fontSize: typography.bodySize,
+ fontWeight: '500',
+ color: colors.textPrimary
+ },
+ subRowLabel: {
+ fontWeight: '400',
+ color: colors.textSecondary
+ },
+ hint: {
+ fontSize: typography.metaSize,
+ color: colors.textMuted,
+ lineHeight: 18,
+ paddingHorizontal: spacing.md + 2,
+ paddingBottom: spacing.md
+ },
+ unsupported: {
+ fontSize: typography.metaSize,
+ color: colors.textMuted,
+ lineHeight: 18,
+ padding: spacing.md + 2
+ }
+})
diff --git a/mobile/src/notifications/NotificationDeliverySection.test.tsx b/mobile/src/notifications/NotificationDeliverySection.test.tsx
new file mode 100644
index 00000000000..f60bb7353b8
--- /dev/null
+++ b/mobile/src/notifications/NotificationDeliverySection.test.tsx
@@ -0,0 +1,45 @@
+import { createElement } from 'react'
+import { act, create } from 'react-test-renderer'
+import { expect, it, vi } from 'vitest'
+import { NotificationDeliverySection } from './NotificationDeliverySection'
+import { DEFAULT_NOTIFICATION_DELIVERY } from './notification-delivery-preferences'
+
+vi.mock('@react-native-async-storage/async-storage', () => ({ default: {} }))
+vi.mock('react-native', () => ({
+ StyleSheet: { create: (value: unknown) => value },
+ View: 'View',
+ Text: 'Text',
+ Switch: 'Switch'
+}))
+
+it('exposes independent event controls only after turning off desktop mirroring', () => {
+ const onChange = vi.fn()
+ let renderer: ReturnType
+ act(() => {
+ renderer = create(
+ createElement(NotificationDeliverySection, { value: DEFAULT_NOTIFICATION_DELIVERY, onChange })
+ )
+ })
+ const switches = () => renderer.root.findAllByType('Switch' as never)
+ expect(switches().map((node) => node.props.accessibilityLabel)).toEqual([
+ 'Use desktop settings',
+ 'Notification sound',
+ 'Suppress while viewing workspace'
+ ])
+ act(() => switches()[0].props.onValueChange(false))
+ const independent = onChange.mock.calls[0][0]
+ expect(independent.followDesktop).toBe(false)
+ act(() =>
+ renderer.update(createElement(NotificationDeliverySection, { value: independent, onChange }))
+ )
+ expect(switches().map((node) => node.props.accessibilityLabel)).toContain('Terminal bell')
+ act(() =>
+ switches()
+ .find((node) => node.props.accessibilityLabel === 'Terminal bell')!
+ .props.onValueChange(false)
+ )
+ expect(onChange).toHaveBeenLastCalledWith(
+ expect.objectContaining({ terminalBell: false, taskFinished: true, needsInput: true })
+ )
+ act(() => renderer.unmount())
+})
diff --git a/mobile/src/notifications/NotificationDeliverySection.tsx b/mobile/src/notifications/NotificationDeliverySection.tsx
new file mode 100644
index 00000000000..5619eeaef84
--- /dev/null
+++ b/mobile/src/notifications/NotificationDeliverySection.tsx
@@ -0,0 +1,71 @@
+import { StyleSheet, Switch, Text, View } from 'react-native'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+import type { NotificationDeliveryPreferences } from './notification-delivery-preferences'
+
+type Props = {
+ value: NotificationDeliveryPreferences
+ disabled?: boolean
+ onChange: (value: NotificationDeliveryPreferences) => void
+}
+
+export function NotificationDeliverySection({ value, disabled, onChange }: Props) {
+ const row = (key: keyof NotificationDeliveryPreferences, label: string) => (
+
+ {label}
+ onChange({ ...value, [key]: enabled })}
+ trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
+ thumbColor={colors.textPrimary}
+ />
+
+ )
+ return (
+
+ {row('followDesktop', 'Use desktop settings')}
+
+ {value.followDesktop
+ ? 'Follow each desktop’s notification and event switches. Desktop focus does not silence this phone.'
+ : 'Choose which alerts reach this phone, both while connected and in the background. Independent delivery requires an updated desktop.'}
+
+ {!value.followDesktop && (
+ <>
+ {row('taskFinished', 'Task finished')}
+ {row('needsInput', 'Needs input')}
+ {row('terminalBell', 'Terminal bell')}
+
+ A program requests attention by sending a bell character. This can happen while an agent
+ is still working.
+
+ {row('plugin', 'Plugin notifications')}
+ >
+ )}
+ {row('sound', 'Notification sound')}
+ {row('suppressWhileViewing', 'Suppress while viewing workspace')}
+
+ Sound and viewing preferences apply only to this phone. Changes reach disconnected desktops
+ when they reconnect.
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ section: {
+ backgroundColor: colors.bgPanel,
+ borderRadius: radii.card,
+ overflow: 'hidden',
+ marginTop: spacing.md
+ },
+ row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, padding: spacing.md },
+ label: { flex: 1, fontSize: typography.bodySize, fontWeight: '500', color: colors.textPrimary },
+ hint: {
+ fontSize: typography.metaSize,
+ color: colors.textMuted,
+ paddingHorizontal: spacing.md,
+ paddingBottom: spacing.md
+ }
+})
diff --git a/mobile/src/notifications/desktop-notification-channel.test.ts b/mobile/src/notifications/desktop-notification-channel.test.ts
new file mode 100644
index 00000000000..c719157cf6b
--- /dev/null
+++ b/mobile/src/notifications/desktop-notification-channel.test.ts
@@ -0,0 +1,62 @@
+import { readFileSync } from 'node:fs'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import * as Notifications from 'expo-notifications'
+import { Platform } from 'react-native'
+import {
+ DESKTOP_NOTIFICATION_CHANNEL_ID,
+ ensureDesktopNotificationChannel
+} from './desktop-notification-channel'
+
+vi.mock('expo-notifications', () => ({
+ AndroidImportance: { HIGH: 'high' },
+ setNotificationChannelAsync: vi.fn()
+}))
+
+vi.mock('react-native', () => ({
+ AppState: { currentState: 'background' },
+ Platform: { OS: 'android' }
+}))
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ Object.assign(Platform, { OS: 'android' })
+ vi.mocked(Notifications.setNotificationChannelAsync).mockResolvedValue(null as never)
+})
+
+describe('ensureDesktopNotificationChannel', () => {
+ it('creates the channel the gateway payload names', () => {
+ ensureDesktopNotificationChannel()
+
+ expect(Notifications.setNotificationChannelAsync).toHaveBeenCalledWith(
+ 'orca-desktop',
+ expect.objectContaining({ importance: 'high' })
+ )
+ expect(DESKTOP_NOTIFICATION_CHANNEL_ID).toBe('orca-desktop')
+ })
+
+ it('does nothing on iOS, which has no notification channels', () => {
+ Object.assign(Platform, { OS: 'ios' })
+
+ ensureDesktopNotificationChannel()
+
+ expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled()
+ })
+
+ it('survives a shell whose channel API rejects', () => {
+ vi.mocked(Notifications.setNotificationChannelAsync).mockRejectedValue(new Error('no channels'))
+
+ expect(() => ensureDesktopNotificationChannel()).not.toThrow()
+ })
+})
+
+describe('app boot', () => {
+ it('creates the channel at startup, not only once a socket subscribes', () => {
+ // A background push can be the first thing to target 'orca-desktop', and Android
+ // drops a notification whose channel does not exist. Asserted against the source
+ // because vitest only collects src/, so app/_layout.tsx has no runtime coverage.
+ const layout = readFileSync(new URL('../../app/_layout.tsx', import.meta.url), 'utf8')
+
+ expect(layout).toContain("from '../src/notifications/desktop-notification-channel'")
+ expect(layout).toMatch(/^ensureDesktopNotificationChannel\(\)$/m)
+ })
+})
diff --git a/mobile/src/notifications/desktop-notification-channel.ts b/mobile/src/notifications/desktop-notification-channel.ts
new file mode 100644
index 00000000000..318c79f8bc4
--- /dev/null
+++ b/mobile/src/notifications/desktop-notification-channel.ts
@@ -0,0 +1,27 @@
+import * as Notifications from 'expo-notifications'
+import { Platform } from 'react-native'
+
+// Why an id both sides share: the gateway's FCM payload names this channel, so a
+// background push can be the first thing that ever targets it. Android drops a
+// notification whose channel does not exist, and the channel used to be created
+// only inside subscribeToDesktopNotifications — i.e. only once a socket connected.
+export const DESKTOP_NOTIFICATION_CHANNEL_ID = 'orca-desktop'
+
+/** Idempotent on Android (the OS updates the existing channel); a no-op elsewhere. */
+export function ensureDesktopNotificationChannel(): void {
+ if (Platform.OS !== 'android') {
+ return
+ }
+ void Notifications.setNotificationChannelAsync(`${DESKTOP_NOTIFICATION_CHANNEL_ID}-silent`, {
+ name: 'Orca silent notifications',
+ importance: Notifications.AndroidImportance.HIGH,
+ sound: null,
+ enableVibrate: false
+ })?.catch(() => {})
+ void Notifications.setNotificationChannelAsync(DESKTOP_NOTIFICATION_CHANNEL_ID, {
+ name: 'Desktop Notifications',
+ importance: Notifications.AndroidImportance.HIGH,
+ vibrationPattern: [0, 250],
+ lightColor: '#6366f1'
+ })?.catch(() => {})
+}
diff --git a/mobile/src/notifications/local-notification-scheduling.ts b/mobile/src/notifications/local-notification-scheduling.ts
index f511346250e..77a80a9a4f0 100644
--- a/mobile/src/notifications/local-notification-scheduling.ts
+++ b/mobile/src/notifications/local-notification-scheduling.ts
@@ -1,11 +1,19 @@
+import { reserveNotificationCooldown } from '../../../src/shared/notification-burst-cooldown'
+import { loadNotificationDeliveryPreferences } from './notification-delivery-preferences'
+import { allowsLocalNotification } from './notification-viewing-policy'
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
import { loadPushNotificationsEnabled } from '../storage/preferences'
+import { DESKTOP_NOTIFICATION_CHANNEL_ID } from './desktop-notification-channel'
import { buildLocalNotificationData, type DesktopNotificationSource } from './notification-routing'
import { ensureNotificationPermissions } from './notification-permissions'
+import { dismissPresentedPushNotification } from './push-tray-dismissal'
export type NotificationEvent = {
type: 'notification'
+ desktopAllowed?: boolean
+ emittedAt?: number
+ agentState?: string
source: DesktopNotificationSource
title: string
body: string
@@ -30,6 +38,19 @@ type ScheduledNotificationState = {
dismissAfterSchedule?: boolean
}
+const recentNotifications = new Map()
+
+function reserveLocalNotification(event: NotificationEvent, hostId: string): boolean {
+ return (
+ event.emittedAt === undefined ||
+ reserveNotificationCooldown(
+ recentNotifications,
+ JSON.stringify([hostId, event.worktreeId ?? 'global']),
+ event.emittedAt
+ )
+ )
+}
+
const scheduledNotificationsByHostAndNotificationId = new Map()
// Why: keys never repeat and are only freed on desktop dismiss (which remote users often miss), so bound the map to stop unbounded growth.
@@ -62,21 +83,17 @@ export function setScheduledNotificationsMaxForTests(max?: number): void {
maxScheduledNotifications = max ?? MAX_SCHEDULED_NOTIFICATIONS
}
-export function configureNotificationChannel(): void {
- if (Platform.OS === 'android') {
- void Notifications.setNotificationChannelAsync('orca-desktop', {
- name: 'Desktop Notifications',
- importance: Notifications.AndroidImportance.HIGH,
- vibrationPattern: [0, 250],
- lightColor: '#6366f1'
- })
- }
-}
-
export async function showLocalNotification(
event: NotificationEvent,
hostId: string
): Promise {
+ if (!(await allowsLocalNotification(event, hostId))) {
+ return
+ }
+ const preferences = await loadNotificationDeliveryPreferences()
+ const channelId = preferences.sound
+ ? DESKTOP_NOTIFICATION_CHANNEL_ID
+ : `${DESKTOP_NOTIFICATION_CHANNEL_ID}-silent`
const storedKey = event.notificationId
? getStoredNotificationKey(hostId, event.notificationId)
: null
@@ -92,12 +109,16 @@ export async function showLocalNotification(
return
}
+ if (!reserveLocalNotification(event, hostId)) {
+ return
+ }
await Notifications.scheduleNotificationAsync({
content: {
title: event.title,
body: event.body,
+ sound: preferences.sound ? 'default' : false,
data: buildLocalNotificationData(event, hostId),
- ...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {})
+ ...(Platform.OS === 'android' ? { channelId } : {})
},
trigger: null
})
@@ -125,6 +146,9 @@ export async function showLocalNotification(
return null
}
+ if (!reserveLocalNotification(event, hostId)) {
+ return null
+ }
if (notificationState.identifier) {
await Notifications.dismissNotificationAsync(notificationState.identifier).catch(() => {})
notificationState.identifier = undefined
@@ -134,8 +158,9 @@ export async function showLocalNotification(
content: {
title: event.title,
body: event.body,
+ sound: preferences.sound ? 'default' : false,
data: buildLocalNotificationData(event, hostId),
- ...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {})
+ ...(Platform.OS === 'android' ? { channelId } : {})
},
trigger: null
})
@@ -173,6 +198,9 @@ export async function dismissLocalNotification(
if (!event.notificationId) {
return
}
+ // Why first and unconditionally: a push the OS presented while Orca was closed has
+ // no entry below, so the local registry alone would leave it in the tray forever.
+ await dismissPresentedPushNotification(event.notificationId)
const storedKey = getStoredNotificationKey(hostId, event.notificationId)
const state = scheduledNotificationsByHostAndNotificationId.get(storedKey)
if (!state) {
diff --git a/mobile/src/notifications/mobile-notifications.test.ts b/mobile/src/notifications/mobile-notifications.test.ts
index d85b1363005..ad6189d1870 100644
--- a/mobile/src/notifications/mobile-notifications.test.ts
+++ b/mobile/src/notifications/mobile-notifications.test.ts
@@ -3,7 +3,6 @@ import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
import {
getNotificationPermissionState,
- setScheduledNotificationsMaxForTests,
subscribeToDesktopNotifications
} from './mobile-notifications'
import AsyncStorage from '@react-native-async-storage/async-storage'
@@ -14,6 +13,7 @@ import { resetHostNotificationSessionsForTests } from './notification-reconnect-
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
setNotificationChannelAsync: vi.fn(),
+ getPresentedNotificationsAsync: vi.fn(async () => []),
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
@@ -21,9 +21,15 @@ vi.mock('expo-notifications', () => ({
}))
vi.mock('react-native', () => ({
+ AppState: { currentState: 'background' },
Platform: { OS: 'ios', Version: 18 }
}))
+// The reconnect catch-up reads the tray to learn which pushes the OS already showed,
+// and mapping those to this host needs the catalog, whose real module pulls the
+// native keychain. No push is presented in these tests, so an empty catalog is enough.
+vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn(async () => []) }))
+
// Why: mobile-notifications now persists the catch-up watermark to
// AsyncStorage. The package isn't resolvable in the node test env (other
// mobile tests mock it the same way), so we provide a no-op mock.
@@ -35,6 +41,7 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
}))
vi.mock('../storage/preferences', () => ({
+ loadRemotePushEnabled: vi.fn(async () => false),
loadPushNotificationsEnabled: vi.fn()
}))
@@ -68,303 +75,6 @@ describe('getNotificationPermissionState', () => {
)
})
-describe('subscribeToDesktopNotifications', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- })
-
- // Why the macrotask and not N microtask ticks (#8591): deliveries now run through
- // the per-host serialization queue, so a delivery is several more `await` hops deep
- // than it used to be and a fixed tick count silently under-drains. Yielding to the
- // macrotask queue drains whatever depth the chain happens to have.
- function flushAsync(): Promise {
- return new Promise((resolve) => {
- setTimeout(resolve, 0)
- })
- }
-
- function makeDeferred(): { promise: Promise; resolve: (value: T) => void } {
- let resolve!: (value: T) => void
- const promise = new Promise((next) => {
- resolve = next
- })
- return { promise, resolve }
- }
-
- it('drops the local stream when disposed before the desktop returns ready', () => {
- const unsubscribeStream = vi.fn()
- const client = {
- subscribe: vi.fn(() => unsubscribeStream),
- getState: vi.fn(() => 'connected'),
- sendRequest: vi.fn()
- } as unknown as RpcClient
-
- const unsubscribe = subscribeToDesktopNotifications(client, 'host-1')
- unsubscribe()
-
- expect(unsubscribeStream).toHaveBeenCalledTimes(1)
- expect(client.sendRequest).not.toHaveBeenCalled()
- })
-
- it('stores scheduled notification identifiers, replaces duplicates, and dismisses by id', async () => {
- vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
- vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
- status: 'granted',
- canAskAgain: true
- } as never)
- vi.mocked(Notifications.scheduleNotificationAsync)
- .mockResolvedValueOnce('scheduled-1')
- .mockResolvedValueOnce('scheduled-2')
- vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
- let onEvent: ((data: unknown) => void) | null = null
- const client = {
- subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
- onEvent = callback
- return vi.fn()
- }),
- getState: vi.fn(() => 'connected'),
- sendRequest: vi.fn()
- } as unknown as RpcClient
-
- subscribeToDesktopNotifications(client, 'host-1')
- onEvent?.({
- type: 'notification',
- source: 'agent-task-complete',
- title: 'Done',
- body: 'Finished.',
- worktreeId: 'repo::/tmp/worktree',
- notificationId: 'agent:one'
- })
- await flushAsync()
- onEvent?.({
- type: 'notification',
- source: 'agent-task-complete',
- title: 'Done again',
- body: 'Finished again.',
- notificationId: 'agent:one'
- })
- await flushAsync()
- expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2)
- onEvent?.({ type: 'dismiss', notificationId: 'agent:one' })
- await flushAsync()
-
- expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2)
- expect(Notifications.scheduleNotificationAsync).toHaveBeenNthCalledWith(
- 1,
- expect.objectContaining({
- content: expect.objectContaining({
- data: expect.objectContaining({
- hostId: 'host-1',
- notificationId: 'agent:one',
- worktreeId: 'repo::/tmp/worktree'
- })
- })
- })
- )
- expect(Notifications.dismissNotificationAsync).toHaveBeenNthCalledWith(1, 'scheduled-1')
- expect(Notifications.dismissNotificationAsync).toHaveBeenNthCalledWith(2, 'scheduled-2')
- })
-
- it('dedupes concurrent notification events with the same desktop notification id', async () => {
- vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
- vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
- status: 'granted',
- canAskAgain: true
- } as never)
- vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
- let onEvent: ((data: unknown) => void) | null = null
- const client = {
- subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
- onEvent = callback
- return vi.fn()
- }),
- getState: vi.fn(() => 'connected'),
- sendRequest: vi.fn()
- } as unknown as RpcClient
-
- subscribeToDesktopNotifications(client, 'host-concurrent')
- onEvent?.({
- type: 'notification',
- source: 'agent-task-complete',
- title: 'Done',
- body: 'Finished.',
- notificationId: 'agent:concurrent'
- })
- onEvent?.({
- type: 'notification',
- source: 'agent-task-complete',
- title: 'Done',
- body: 'Finished.',
- notificationId: 'agent:concurrent'
- })
- await flushAsync()
-
- expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(1)
- })
-
- it('dismisses a notification when dismiss arrives while scheduling is pending', async () => {
- vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
- vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
- status: 'granted',
- canAskAgain: true
- } as never)
- let resolveSchedule!: (identifier: string) => void
- vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(
- () =>
- new Promise((resolve) => {
- resolveSchedule = resolve
- })
- )
- vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
- let onEvent: ((data: unknown) => void) | null = null
- const client = {
- subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
- onEvent = callback
- return vi.fn()
- }),
- getState: vi.fn(() => 'connected'),
- sendRequest: vi.fn()
- } as unknown as RpcClient
-
- subscribeToDesktopNotifications(client, 'host-dismiss-race')
- onEvent?.({
- type: 'notification',
- source: 'agent-task-complete',
- title: 'Done',
- body: 'Finished.',
- notificationId: 'agent:pending'
- })
- await flushAsync()
- onEvent?.({ type: 'dismiss', notificationId: 'agent:pending' })
- resolveSchedule('scheduled-pending')
- await flushAsync()
-
- expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-pending')
- })
-
- it('does not carry a failed pending dismiss into a future schedule', async () => {
- const secondEnabled = makeDeferred()
- vi.mocked(loadPushNotificationsEnabled)
- .mockResolvedValueOnce(true)
- .mockReturnValueOnce(secondEnabled.promise)
- .mockResolvedValueOnce(true)
- vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
- status: 'granted',
- canAskAgain: true
- } as never)
- vi.mocked(Notifications.scheduleNotificationAsync)
- .mockResolvedValueOnce('scheduled-1')
- .mockResolvedValueOnce('scheduled-2')
- vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
- let onEvent: ((data: unknown) => void) | null = null
- const client = {
- subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
- onEvent = callback
- return vi.fn()
- }),
- getState: vi.fn(() => 'connected'),
- sendRequest: vi.fn()
- } as unknown as RpcClient
-
- subscribeToDesktopNotifications(client, 'host-dismiss-failed-replacement')
- onEvent?.({
- type: 'notification',
- source: 'agent-task-complete',
- title: 'Done',
- body: 'Finished.',
- notificationId: 'agent:stale-dismiss'
- })
- await flushAsync()
- onEvent?.({
- type: 'notification',
- source: 'agent-task-complete',
- title: 'Done again',
- body: 'Finished again.',
- notificationId: 'agent:stale-dismiss'
- })
- await flushAsync()
- onEvent?.({ type: 'dismiss', notificationId: 'agent:stale-dismiss' })
- secondEnabled.resolve(false)
- await flushAsync()
-
- onEvent?.({
- type: 'notification',
- source: 'agent-task-complete',
- title: 'Done later',
- body: 'Finished later.',
- notificationId: 'agent:stale-dismiss'
- })
- await flushAsync()
-
- expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2)
- expect(Notifications.dismissNotificationAsync).toHaveBeenCalledTimes(1)
- expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-1')
- })
-
- it('treats unknown dismiss events as no-ops', async () => {
- vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
- let onEvent: ((data: unknown) => void) | null = null
- const client = {
- subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
- onEvent = callback
- return vi.fn()
- }),
- getState: vi.fn(() => 'connected'),
- sendRequest: vi.fn()
- } as unknown as RpcClient
-
- subscribeToDesktopNotifications(client, 'host-unknown')
- onEvent?.({ type: 'dismiss', notificationId: 'agent:missing' })
- await flushAsync()
-
- expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled()
- })
-
- // Why: notificationId is unique per completion, so the map grew unbounded when
- // the desktop never sent a dismiss (the remote-mobile case). It is now capped.
- it('evicts the oldest scheduled entry once the cap is exceeded', async () => {
- setScheduledNotificationsMaxForTests(1)
- try {
- vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
- vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
- status: 'granted',
- canAskAgain: true
- } as never)
- vi.mocked(Notifications.scheduleNotificationAsync)
- .mockResolvedValueOnce('scheduled-old')
- .mockResolvedValueOnce('scheduled-new')
- vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
- let onEvent: ((data: unknown) => void) | null = null
- const client = {
- subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
- onEvent = callback
- return vi.fn()
- }),
- getState: vi.fn(() => 'connected'),
- sendRequest: vi.fn()
- } as unknown as RpcClient
-
- subscribeToDesktopNotifications(client, 'host-1')
- onEvent?.({ type: 'notification', title: 't', body: 'b', notificationId: 'agent:old' })
- await flushAsync()
- onEvent?.({ type: 'notification', title: 't', body: 'b', notificationId: 'agent:new' })
- await flushAsync()
-
- // The older entry was evicted by the cap: dismissing it is a no-op...
- onEvent?.({ type: 'dismiss', notificationId: 'agent:old' })
- await flushAsync()
- expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalledWith('scheduled-old')
-
- // ...while the most-recent entry is retained and still dismissable.
- onEvent?.({ type: 'dismiss', notificationId: 'agent:new' })
- await flushAsync()
- expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-new')
- } finally {
- setScheduledNotificationsMaxForTests()
- }
- })
-})
-
// Why: #8129 catch-up. On a reconnect the live stream re-emits `ready`; the
// client must fetch missed notifications from its watermark and push exactly
// the ones it had not yet delivered — never re-pushing an already-delivered id.
@@ -452,6 +162,7 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
notifications: [
{
type: 'notification',
+ source: 'agent-task-complete',
title: 'missed',
body: 'b',
notificationId: 'agent:missed',
@@ -471,6 +182,7 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
// Live stream already delivered agent:dup (seq 11) before reap.
sub.onData?.({
type: 'notification',
+ source: 'agent-task-complete',
title: 'dup',
body: 'b',
notificationId: 'agent:dup',
@@ -486,7 +198,7 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
const missedCall = vi
.mocked(sub.client.sendRequest)
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
- expect(missedCall?.[1]).toEqual({ lastSeenSeq: 11 })
+ expect(missedCall?.[1]).toEqual({ includeDesktopSuppressed: true, lastSeenSeq: 11 })
// Only agent:missed was pushed; agent:dup appears exactly once (live only).
const scheduledIds = vi
.mocked(Notifications.scheduleNotificationAsync)
@@ -532,10 +244,18 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
.mock.calls.filter((c: unknown[]) => c[0] === 'notifications.getMissedSince')
// The cold open catches up from its stored watermark against the SAME counter —
// 57 is meaningful there, so it is the correct cut (#8591 second pass).
- expect(missedCalls[0]?.[1]).toEqual({ lastSeenSeq: 57, epoch: 'epoch-before-restart' })
+ expect(missedCalls[0]?.[1]).toEqual({
+ includeDesktopSuppressed: true,
+ lastSeenSeq: 57,
+ epoch: 'epoch-before-restart'
+ })
// After the restart the watermark is reset to 0 and tagged with the live epoch —
// not the stale 57, which would make `57 >= 2` true and kill catch-up silently.
- expect(missedCalls.at(-1)?.[1]).toEqual({ lastSeenSeq: 0, epoch: 'epoch-after-restart' })
+ expect(missedCalls.at(-1)?.[1]).toEqual({
+ includeDesktopSuppressed: true,
+ lastSeenSeq: 0,
+ epoch: 'epoch-after-restart'
+ })
})
it('refuses to seed a stored watermark that lost the race to a newer live epoch', async () => {
@@ -579,7 +299,11 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
const missedCall = vi
.mocked(sub.client.sendRequest)
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
- expect(missedCall?.[1]).toEqual({ lastSeenSeq: 0, epoch: 'epoch-after-restart' })
+ expect(missedCall?.[1]).toEqual({
+ includeDesktopSuppressed: true,
+ lastSeenSeq: 0,
+ epoch: 'epoch-after-restart'
+ })
})
it('keeps the persisted watermark when the desktop epoch is unchanged', async () => {
@@ -609,7 +333,11 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
const missedCall = vi
.mocked(sub.client.sendRequest)
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
- expect(missedCall?.[1]).toEqual({ lastSeenSeq: 57, epoch: 'epoch-stable' })
+ expect(missedCall?.[1]).toEqual({
+ includeDesktopSuppressed: true,
+ lastSeenSeq: 57,
+ epoch: 'epoch-stable'
+ })
})
it('drops an already-seen id if a replay re-includes it (defense-in-depth)', async () => {
@@ -631,6 +359,7 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
notifications: [
{
type: 'notification',
+ source: 'agent-task-complete',
title: 'dup',
body: 'b',
notificationId: 'agent:dup',
@@ -638,6 +367,7 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
},
{
type: 'notification',
+ source: 'agent-task-complete',
title: 'new',
body: 'b',
notificationId: 'agent:new',
@@ -656,6 +386,7 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
// Live stream delivered agent:dup (seq 11) before reap.
sub.onData?.({
type: 'notification',
+ source: 'agent-task-complete',
title: 'dup',
body: 'b',
notificationId: 'agent:dup',
@@ -692,6 +423,7 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
// Live stream delivers seq 5.
sub.onData?.({
type: 'notification',
+ source: 'agent-task-complete',
title: 't',
body: 'b',
notificationId: 'agent:live',
@@ -728,6 +460,7 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
notifications: [
{
type: 'notification',
+ source: 'agent-task-complete',
title: 'missed',
body: 'b',
notificationId: 'agent:missed',
@@ -760,7 +493,7 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
const missedCalls = vi
.mocked(sub.client.sendRequest)
.mock.calls.filter((c: unknown[]) => c[0] === 'notifications.getMissedSince')
- expect(missedCalls.at(-1)?.[1]).toEqual({ lastSeenSeq: 8 })
+ expect(missedCalls.at(-1)?.[1]).toEqual({ includeDesktopSuppressed: true, lastSeenSeq: 8 })
})
it('replays a terminal bell at a seq the previous desktop counter already used', async () => {
@@ -785,7 +518,15 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
ok: true,
result: {
epoch: 'epoch-B',
- notifications: [{ type: 'notification', title: 'bell', body: 'B', notificationSeq: 1 }]
+ notifications: [
+ {
+ type: 'notification',
+ source: 'agent-task-complete',
+ title: 'bell',
+ body: 'B',
+ notificationSeq: 1
+ }
+ ]
}
} as never
}
@@ -796,7 +537,13 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-A' })
await flushAsync()
// A live bell under epoch A — no notificationId, so its seen-key is `seq:1`.
- sub.onData?.({ type: 'notification', title: 'bell', body: 'A', notificationSeq: 1 })
+ sub.onData?.({
+ type: 'notification',
+ source: 'agent-task-complete',
+ title: 'bell',
+ body: 'A',
+ notificationSeq: 1
+ })
await flushAsync()
expect(vi.mocked(Notifications.scheduleNotificationAsync).mock.calls.length).toBe(1)
@@ -841,7 +588,11 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
.mocked(sub.client.sendRequest)
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
// Must not be 57: that seq was never shown to belong to this counter.
- expect(missedCall?.[1]).toEqual({ lastSeenSeq: 0, epoch: 'epoch-live' })
+ expect(missedCall?.[1]).toEqual({
+ includeDesktopSuppressed: true,
+ lastSeenSeq: 0,
+ epoch: 'epoch-live'
+ })
})
it('catches up on the FIRST connection after an upgrade, without a second ready', async () => {
@@ -875,6 +626,7 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
notifications: [
{
type: 'notification',
+ source: 'agent-task-complete',
notificationId: 'missed-58',
notificationSeq: 58,
notificationEpoch: 'epoch-live',
@@ -896,7 +648,11 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
.mocked(sub.client.sendRequest)
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
// The single 'ready' must replay from the stored watermark, not skip it.
- expect(missedCall?.[1]).toEqual({ lastSeenSeq: 57, epoch: 'epoch-live' })
+ expect(missedCall?.[1]).toEqual({
+ includeDesktopSuppressed: true,
+ lastSeenSeq: 57,
+ epoch: 'epoch-live'
+ })
// And the missed notification must actually reach the user.
expect(vi.mocked(Notifications.scheduleNotificationAsync).mock.calls.length).toBe(1)
})
@@ -944,6 +700,7 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
await flushAsync()
sub.onData?.({
type: 'notification',
+ source: 'agent-task-complete',
title: 't',
body: 'b',
notificationId: 'agent:x',
diff --git a/mobile/src/notifications/mobile-notifications.ts b/mobile/src/notifications/mobile-notifications.ts
index 0043762e3ec..1ab9c6fcd94 100644
--- a/mobile/src/notifications/mobile-notifications.ts
+++ b/mobile/src/notifications/mobile-notifications.ts
@@ -1,5 +1,5 @@
+import { waitForSocketPushHandoff } from './socket-push-delivery-handoff'
import type { RpcClient } from '../transport/rpc-client'
-// Re-exported so the existing importers (and their vi.mock paths) keep working.
export {
ensureNotificationPermissions,
getNotificationPermissionState,
@@ -7,12 +7,12 @@ export {
} from './notification-permissions'
export { setScheduledNotificationsMaxForTests } from './local-notification-scheduling'
import {
- configureNotificationChannel,
dismissLocalNotification,
showLocalNotification,
type DismissNotificationEvent,
type NotificationEvent
} from './local-notification-scheduling'
+import { ensureDesktopNotificationChannel } from './desktop-notification-channel'
import {
adoptNotificationEpoch,
catchUpWatermarkSeq,
@@ -26,6 +26,7 @@ import {
seenKeyForEvent,
shouldQueueShowForNotificationId
} from './notification-reconnect-catchup'
+import { markPresentedPushesSeen, readPresentedPushSeenKeys } from './push-tray-seen-seed'
type SubscribeResult = {
type: 'ready'
@@ -34,14 +35,13 @@ type SubscribeResult = {
epoch?: string
}
-// Per-connection subscription; a reconnect `ready` triggers watermarked catch-up (#8129) so already-pushed events aren't re-sent.
export function subscribeToDesktopNotifications(client: RpcClient, hostId: string): () => void {
- configureNotificationChannel()
+ ensureDesktopNotificationChannel()
let subscriptionId: string | null = null
let disposed = false
- // Why (#8591): survives the unsubscribe/resubscribe the app performs on every
- // socket drop, so a reconnect still knows its watermark and that it reconnected.
+ const deliveryAbort = new AbortController()
+ // Preserve the watermark across socket reconnects.
const session = getHostNotificationSession(hostId)
/**
@@ -84,23 +84,26 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
adoptNotificationEpoch(session, hostId, event.notificationEpoch)
const epochAtDelivery = session.lastDeliveredEpoch
if (type === 'notification') {
- await showLocalNotification(event as NotificationEvent, hostId)
+ const show = await waitForSocketPushHandoff(
+ event as NotificationEvent,
+ hostId,
+ deliveryAbort.signal
+ )
+ if (disposed) {
+ throw new Error('notification_subscription_disposed')
+ }
+ if (show) {
+ await showLocalNotification(event as NotificationEvent, hostId)
+ }
} else {
await dismissLocalNotification(event as DismissNotificationEvent, hostId)
}
- // Why after the await, exactly like the watermark below: `seen` asserts this event
- // reached the user (#8129). Marked before, a rejected show leaves the key behind and
- // every later replay is dropped as a duplicate — loss the quarantine cannot recover,
- // since the first event to drain a batch lifts it past the one never shown.
+ // Claim only after local delivery or a matching presented push.
const key = seenKeyForEvent(event)
// A mid-flight epoch adoption already cleared the counter lifetime this key indexes.
if (key && session.lastDeliveredEpoch === epochAtDelivery) {
session.seen.add(key)
}
- // Why after the await (#8591): the watermark is a promise that everything up
- // to this seq has been shown. Advancing it before the local notification lands
- // means a process death in between silently drops it — the next launch asks the
- // desktop for seq greater than one the user never saw.
if (event.notificationSeq != null && event.notificationSeq > session.lastDeliveredSeq) {
session.lastDeliveredSeq = event.notificationSeq
// Why clamped: while a failed catch-up's range is still unrecovered, persisting
@@ -113,12 +116,9 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
}
}
- // Claimed inline rather than via queueDelivery: the batch is already one queue
- // entry, and re-enqueueing per item is what let a live event cut in.
async function deliverMissedEvent(
event: NotificationEvent | DismissNotificationEvent
): Promise {
- // No pre-marking here either: deliverLive marks the key once the show lands.
const key = seenKeyForEvent(event)
if (key && session.seen.has(key)) {
return
@@ -144,12 +144,14 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
if (disposed) {
return
}
- // Captured before the request: everything at or below it is known delivered, so
- // it is the floor the watermark falls back to if this catch-up never completes.
+ // Preserve the delivered floor if catch-up fails.
const askFrom = catchUpWatermarkSeq(session)
+ // Read concurrently; claim inside the queue after epoch adoption to avoid stale keys.
+ const presentedPushKeys = readPresentedPushSeenKeys(hostId)
const missed = await client
.sendRequest('notifications.getMissedSince', {
lastSeenSeq: askFrom,
+ includeDesktopSuppressed: true,
// Why: sending the epoch lets the desktop reject a watermark from a counter
// it no longer has and return the whole retained buffer instead of nothing.
...(session.lastDeliveredEpoch != null ? { epoch: session.lastDeliveredEpoch } : {})
@@ -176,8 +178,7 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
// request stays OUTSIDE the queue: sendRequest waits up to 30s, and holding the
// chain for that would stall live delivery on a slow link.
await enqueueHostDelivery(session, async () => {
- // Advances only past events this batch settled, so a teardown or a failing show
- // quarantines the true contiguous point instead of the range it never reached.
+ markPresentedPushesSeen(session, await presentedPushKeys)
let contiguousSeq = askFrom
let drained = false
try {
@@ -213,7 +214,8 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
}
}
- const unsubscribeStream = client.subscribe('notifications.subscribe', {}, (data: unknown) => {
+ const params = { includeDesktopSuppressed: true }
+ const unsubscribeStream = client.subscribe('notifications.subscribe', params, (data: unknown) => {
const event = data as
| NotificationEvent
| DismissNotificationEvent
@@ -285,6 +287,7 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
return () => {
disposed = true
+ deliveryAbort.abort()
// Why: drop the local stream first — readiness can race unmount; don't hold the callback while a subscription id is pending.
unsubscribeStream()
if (subscriptionId) {
diff --git a/mobile/src/notifications/native-notification-data.test.ts b/mobile/src/notifications/native-notification-data.test.ts
new file mode 100644
index 00000000000..2b157a5fda6
--- /dev/null
+++ b/mobile/src/notifications/native-notification-data.test.ts
@@ -0,0 +1,22 @@
+import { expect, it } from 'vitest'
+import { readNativeNotificationData } from './native-notification-data'
+import { readOrcaPushPayload } from './push-payload'
+
+it('reads actual Expo APNs payloads when content.data is null', () => {
+ const orca = {
+ hostFingerprint: 'qa-host',
+ notificationId: 'done',
+ notificationSeq: 4,
+ notificationEpoch: 'epoch'
+ }
+ const data = readNativeNotificationData({
+ content: { data: null },
+ trigger: { type: 'push', payload: { aps: {}, orca } }
+ })
+ expect(readOrcaPushPayload(data)).toMatchObject(orca)
+})
+it('keeps Android push and local notification data', () => {
+ const data = { hostId: 'host', notificationId: 'done' }
+ expect(readNativeNotificationData({ content: { data }, trigger: { type: 'push' } })).toBe(data)
+ expect(readNativeNotificationData({ content: { data }, trigger: null })).toBe(data)
+})
diff --git a/mobile/src/notifications/native-notification-data.ts b/mobile/src/notifications/native-notification-data.ts
new file mode 100644
index 00000000000..74d50397660
--- /dev/null
+++ b/mobile/src/notifications/native-notification-data.ts
@@ -0,0 +1,13 @@
+export function readNativeNotificationData(request: {
+ content: { data?: unknown }
+ trigger?: unknown
+}): unknown {
+ const trigger = request.trigger
+ if (trigger && typeof trigger === 'object' && 'type' in trigger && trigger.type === 'push') {
+ // Expo iOS keeps raw APNs custom fields here when content.data is null.
+ if ('payload' in trigger && trigger.payload && typeof trigger.payload === 'object') {
+ return trigger.payload
+ }
+ }
+ return request.content.data
+}
diff --git a/mobile/src/notifications/notification-catchup-failure-quarantine.test.ts b/mobile/src/notifications/notification-catchup-failure-quarantine.test.ts
index 997b9fce930..c9f6595f576 100644
--- a/mobile/src/notifications/notification-catchup-failure-quarantine.test.ts
+++ b/mobile/src/notifications/notification-catchup-failure-quarantine.test.ts
@@ -8,6 +8,7 @@ import { loadPushNotificationsEnabled } from '../storage/preferences'
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
setNotificationChannelAsync: vi.fn(),
+ getPresentedNotificationsAsync: vi.fn(async () => []),
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
@@ -15,9 +16,15 @@ vi.mock('expo-notifications', () => ({
}))
vi.mock('react-native', () => ({
+ AppState: { currentState: 'background' },
Platform: { OS: 'ios', Version: 18 }
}))
+// The reconnect catch-up reads the tray to learn which pushes the OS already showed,
+// and mapping those to this host needs the catalog, whose real module pulls the
+// native keychain. No push is presented in these tests, so an empty catalog is enough.
+vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn(async () => []) }))
+
const WATERMARK_KEY = 'orca:mobileNotificationsWatermark:host-1'
const storage = new Map()
@@ -31,6 +38,7 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
}))
vi.mock('../storage/preferences', () => ({
+ loadRemotePushEnabled: vi.fn(async () => false),
loadPushNotificationsEnabled: vi.fn()
}))
@@ -68,7 +76,9 @@ function makeHostClient() {
if (method !== 'notifications.getMissedSince') {
return { ok: true, result: undefined } as never
}
- askedFrom.push((params as { lastSeenSeq: number }).lastSeenSeq)
+ askedFrom.push(
+ (params as { includeDesktopSuppressed: true; lastSeenSeq: number }).lastSeenSeq
+ )
if (outcome.kind === 'heldReject') {
await new Promise((resolve) => {
releaseHeld = resolve
@@ -102,6 +112,7 @@ function makeHostClient() {
function notification(seq: number) {
return {
type: 'notification',
+ source: 'agent-task-complete',
title: `m${seq}`,
body: 'b',
notificationId: `agent:${seq}`,
diff --git a/mobile/src/notifications/notification-delivery-ordering.test.ts b/mobile/src/notifications/notification-delivery-ordering.test.ts
index 68d64d7b3de..5960c8c524d 100644
--- a/mobile/src/notifications/notification-delivery-ordering.test.ts
+++ b/mobile/src/notifications/notification-delivery-ordering.test.ts
@@ -8,6 +8,7 @@ import { loadPushNotificationsEnabled } from '../storage/preferences'
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
setNotificationChannelAsync: vi.fn(),
+ getPresentedNotificationsAsync: vi.fn(async () => []),
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
@@ -15,9 +16,15 @@ vi.mock('expo-notifications', () => ({
}))
vi.mock('react-native', () => ({
+ AppState: { currentState: 'background' },
Platform: { OS: 'ios', Version: 18 }
}))
+// The reconnect catch-up reads the tray to learn which pushes the OS already showed,
+// and mapping those to this host needs the catalog, whose real module pulls the
+// native keychain. No push is presented in these tests, so an empty catalog is enough.
+vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn(async () => []) }))
+
const WATERMARK_KEY = 'orca:mobileNotificationsWatermark:host-1'
const storage = new Map()
let getItemImpl: (key: string) => Promise = async (key) => storage.get(key) ?? null
@@ -32,6 +39,7 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
}))
vi.mock('../storage/preferences', () => ({
+ loadRemotePushEnabled: vi.fn(async () => false),
loadPushNotificationsEnabled: vi.fn()
}))
@@ -94,6 +102,7 @@ describe('#8591 per-host delivery ordering', () => {
notifications: [
{
type: 'notification',
+ source: 'agent-task-complete',
title: 'm6',
body: 'b',
notificationId: 'a:6',
@@ -101,6 +110,7 @@ describe('#8591 per-host delivery ordering', () => {
},
{
type: 'notification',
+ source: 'agent-task-complete',
title: 'm7',
body: 'b',
notificationId: 'a:7',
@@ -122,6 +132,7 @@ describe('#8591 per-host delivery ordering', () => {
// Live seq 11 arrives while the replay is wedged on seq 6.
onData?.({
type: 'notification',
+ source: 'agent-task-complete',
title: 'live-11',
body: 'b',
notificationId: 'a:11',
@@ -174,6 +185,7 @@ describe('#8591 per-host delivery ordering', () => {
notifications: [
{
type: 'notification',
+ source: 'agent-task-complete',
title: 'dup',
body: 'b',
notificationId: 'agent:dup',
@@ -196,6 +208,7 @@ describe('#8591 per-host delivery ordering', () => {
// seq, so the seen-set does not catch it — only the queued-show claim does.
onData?.({
type: 'notification',
+ source: 'agent-task-complete',
title: 'dup',
body: 'b',
notificationId: 'agent:dup',
@@ -212,7 +225,10 @@ describe('#8591 per-host delivery ordering', () => {
it('still delivers when the persisted watermark read never resolves', async () => {
// Every delivery awaits the seed, so a wedged AsyncStorage read would disable
// this host's notifications for the whole app lifetime — silently.
- getItemImpl = () => new Promise(() => {})
+ getItemImpl = (key) =>
+ key.startsWith('orca:mobileNotificationsWatermark:')
+ ? new Promise(() => {})
+ : Promise.resolve(null)
let onData: ((data: unknown) => void) | null = null
const client = {
@@ -230,6 +246,7 @@ describe('#8591 per-host delivery ordering', () => {
onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
onData?.({
type: 'notification',
+ source: 'agent-task-complete',
title: 'live-1',
body: 'b',
notificationId: 'a:1',
diff --git a/mobile/src/notifications/notification-delivery-preferences.test.ts b/mobile/src/notifications/notification-delivery-preferences.test.ts
new file mode 100644
index 00000000000..b6c38616fb9
--- /dev/null
+++ b/mobile/src/notifications/notification-delivery-preferences.test.ts
@@ -0,0 +1,87 @@
+import { beforeEach, expect, it, vi } from 'vitest'
+import { AppState } from 'react-native'
+import {
+ DEFAULT_NOTIFICATION_DELIVERY,
+ loadNotificationDeliveryPreferences,
+ notificationPreferencesFilter,
+ saveNotificationDeliveryPreferences
+} from './notification-delivery-preferences'
+import {
+ allowsLocalNotification,
+ setNotificationViewingWorkspace
+} from './notification-viewing-policy'
+import { allowsMobileNotification } from '../../../src/shared/mobile-notification-policy'
+
+const storage = new Map()
+vi.mock('@react-native-async-storage/async-storage', () => ({
+ default: {
+ getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
+ setItem: vi.fn(async (key: string, value: string) => {
+ storage.set(key, value)
+ })
+ }
+}))
+vi.mock('react-native', () => ({ AppState: { currentState: 'background' } }))
+beforeEach(() => {
+ storage.clear()
+ setNotificationViewingWorkspace(null)
+ AppState.currentState = 'background'
+})
+
+it('defaults to following desktop and persists independent event preferences', async () => {
+ expect(await loadNotificationDeliveryPreferences()).toEqual(DEFAULT_NOTIFICATION_DELIVERY)
+ const value = {
+ ...DEFAULT_NOTIFICATION_DELIVERY,
+ followDesktop: false,
+ terminalBell: false,
+ sound: false
+ }
+ await saveNotificationDeliveryPreferences(value)
+ expect(await loadNotificationDeliveryPreferences()).toEqual(value)
+ expect(notificationPreferencesFilter(value)).toMatchObject({
+ followDesktop: false,
+ sound: false,
+ sources: ['agent-task-complete', 'plugin']
+ })
+})
+
+it('preserves explicitly narrowed filters from before the new settings screen', async () => {
+ storage.set('orca:remotePushAgentStates', '["needs-input"]')
+ expect(await loadNotificationDeliveryPreferences()).toMatchObject({
+ followDesktop: false,
+ needsInput: true,
+ taskFinished: false
+ })
+})
+
+it.each(['agent-task-complete', 'terminal-bell', 'plugin'])(
+ 'uses identical type filtering for socket/replay and background push: %s',
+ async (source) => {
+ for (const followDesktop of [true, false]) {
+ const value = {
+ ...DEFAULT_NOTIFICATION_DELIVERY,
+ followDesktop,
+ terminalBell: false,
+ taskFinished: false
+ }
+ await saveNotificationDeliveryPreferences(value)
+ for (const desktopAllowed of [true, false]) {
+ const event = { source, desktopAllowed, agentState: 'done' }
+ expect(await allowsLocalNotification(event, 'host')).toBe(
+ allowsMobileNotification(notificationPreferencesFilter(value), event)
+ )
+ }
+ }
+ }
+)
+
+it('suppresses only the workspace being viewed on this phone, and never while backgrounded', async () => {
+ const event = { source: 'terminal-bell', worktreeId: 'folder-id' }
+ setNotificationViewingWorkspace({ hostId: 'ssh-host', worktreeId: 'folder-id' })
+ AppState.currentState = 'active'
+ expect(await allowsLocalNotification(event, 'ssh-host')).toBe(false)
+ expect(await allowsLocalNotification(event, 'another-host')).toBe(true)
+ expect(await allowsLocalNotification({ ...event, worktreeId: 'other' }, 'ssh-host')).toBe(true)
+ AppState.currentState = 'background'
+ expect(await allowsLocalNotification(event, 'ssh-host')).toBe(true)
+})
diff --git a/mobile/src/notifications/notification-delivery-preferences.ts b/mobile/src/notifications/notification-delivery-preferences.ts
new file mode 100644
index 00000000000..ad56e3ff6b0
--- /dev/null
+++ b/mobile/src/notifications/notification-delivery-preferences.ts
@@ -0,0 +1,88 @@
+import AsyncStorage from '@react-native-async-storage/async-storage'
+import {
+ MOBILE_PUSH_AGENT_STATES,
+ MOBILE_PUSH_SOURCES,
+ type MobilePushFilter
+} from '../../../src/shared/mobile-push-contract'
+
+const KEY = 'orca:notificationDeliveryPreferences'
+export type NotificationDeliveryPreferences = {
+ followDesktop: boolean
+ taskFinished: boolean
+ needsInput: boolean
+ terminalBell: boolean
+ plugin: boolean
+ sound: boolean
+ suppressWhileViewing: boolean
+}
+
+export const DEFAULT_NOTIFICATION_DELIVERY: NotificationDeliveryPreferences = {
+ followDesktop: true,
+ taskFinished: true,
+ needsInput: true,
+ terminalBell: true,
+ plugin: true,
+ sound: true,
+ suppressWhileViewing: true
+}
+
+export async function loadNotificationDeliveryPreferences(): Promise {
+ const raw = await AsyncStorage.getItem(KEY)
+ if (!raw) {
+ // Preserve an existing explicit background filter when upgrading.
+ const legacy = await AsyncStorage.getItem('orca:remotePushAgentStates')
+ if (legacy) {
+ const states: unknown = JSON.parse(legacy)
+ if (Array.isArray(states)) {
+ return {
+ ...DEFAULT_NOTIFICATION_DELIVERY,
+ followDesktop: false,
+ taskFinished: states.includes('finished'),
+ needsInput: states.includes('needs-input')
+ }
+ }
+ }
+ return { ...DEFAULT_NOTIFICATION_DELIVERY }
+ }
+ const stored = JSON.parse(raw) as Record
+ const result = { ...DEFAULT_NOTIFICATION_DELIVERY }
+ for (const key of Object.keys(result) as (keyof NotificationDeliveryPreferences)[]) {
+ if (typeof stored?.[key] === 'boolean') {
+ result[key] = stored[key]
+ }
+ }
+ return result
+}
+
+export async function saveNotificationDeliveryPreferences(
+ value: NotificationDeliveryPreferences
+): Promise {
+ await AsyncStorage.setItem(KEY, JSON.stringify(value))
+}
+
+export function notificationPreferencesFilter(
+ value: NotificationDeliveryPreferences
+): MobilePushFilter {
+ if (value.followDesktop) {
+ return {
+ sound: value.sound,
+ followDesktop: true,
+ sources: MOBILE_PUSH_SOURCES,
+ agentStates: MOBILE_PUSH_AGENT_STATES
+ }
+ }
+ return {
+ followDesktop: false,
+ sound: value.sound,
+ sources: MOBILE_PUSH_SOURCES.filter((source) =>
+ source === 'terminal-bell'
+ ? value.terminalBell
+ : source === 'plugin'
+ ? value.plugin
+ : value.needsInput || value.taskFinished
+ ),
+ agentStates: MOBILE_PUSH_AGENT_STATES.filter((state) =>
+ state === 'needs-input' ? value.needsInput : value.taskFinished
+ )
+ }
+}
diff --git a/mobile/src/notifications/notification-local-delivery.test.ts b/mobile/src/notifications/notification-local-delivery.test.ts
new file mode 100644
index 00000000000..18c19daba7d
--- /dev/null
+++ b/mobile/src/notifications/notification-local-delivery.test.ts
@@ -0,0 +1,211 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import * as Notifications from 'expo-notifications'
+import AsyncStorage from '@react-native-async-storage/async-storage'
+import { showLocalNotification } from './local-notification-scheduling'
+import { Platform } from 'react-native'
+import { subscribeToDesktopNotifications } from './mobile-notifications'
+import type { RpcClient } from '../transport/rpc-client'
+import { loadPushNotificationsEnabled } from '../storage/preferences'
+import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
+
+vi.mock('expo-notifications', () => ({
+ AndroidImportance: { HIGH: 'high' },
+ setNotificationChannelAsync: vi.fn(),
+ getPresentedNotificationsAsync: vi.fn(async () => []),
+ getPermissionsAsync: vi.fn(),
+ requestPermissionsAsync: vi.fn(),
+ scheduleNotificationAsync: vi.fn(),
+ dismissNotificationAsync: vi.fn()
+}))
+
+vi.mock('react-native', () => ({
+ AppState: { currentState: 'background' },
+ Platform: { OS: 'ios', Version: 18 }
+}))
+
+// The reconnect catch-up reads the tray to learn which pushes the OS already showed,
+// and mapping those to this host needs the catalog, whose real module pulls the
+// native keychain. No push is presented in these tests, so an empty catalog is enough.
+vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn(async () => []) }))
+
+// Why: mobile-notifications now persists the catch-up watermark to
+// AsyncStorage. The package isn't resolvable in the node test env (other
+// mobile tests mock it the same way), so we provide a no-op mock.
+vi.mock('@react-native-async-storage/async-storage', () => ({
+ default: {
+ getItem: vi.fn(async () => null),
+ setItem: vi.fn(async () => undefined)
+ }
+}))
+
+vi.mock('../storage/preferences', () => ({
+ loadRemotePushEnabled: vi.fn(async () => false),
+ loadPushNotificationsEnabled: vi.fn()
+}))
+
+beforeEach(() => {
+ Object.assign(Platform, { OS: 'ios', Version: 18 })
+ // Why (#8591): the reconnect watermark/seen-set now live per host at module
+ // scope so they survive the app's unsubscribe-on-disconnect. Reset between
+ // tests so each case starts from a genuine cold open.
+ resetHostNotificationSessionsForTests()
+})
+
+describe('subscribeToDesktopNotifications', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // Why the macrotask and not N microtask ticks (#8591): deliveries now run through
+ // the per-host serialization queue, so a delivery is several more `await` hops deep
+ // than it used to be and a fixed tick count silently under-drains. Yielding to the
+ // macrotask queue drains whatever depth the chain happens to have.
+ function flushAsync(): Promise {
+ return new Promise((resolve) => {
+ setTimeout(resolve, 0)
+ })
+ }
+
+ it('drops the local stream when disposed before the desktop returns ready', () => {
+ const unsubscribeStream = vi.fn()
+ const client = {
+ subscribe: vi.fn(() => unsubscribeStream),
+ getState: vi.fn(() => 'connected'),
+ sendRequest: vi.fn()
+ } as unknown as RpcClient
+
+ const unsubscribe = subscribeToDesktopNotifications(client, 'host-1')
+ unsubscribe()
+
+ expect(unsubscribeStream).toHaveBeenCalledTimes(1)
+ expect(client.sendRequest).not.toHaveBeenCalled()
+ })
+
+ it('stores scheduled notification identifiers, replaces duplicates, and dismisses by id', async () => {
+ vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
+ vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
+ status: 'granted',
+ canAskAgain: true
+ } as never)
+ vi.mocked(Notifications.scheduleNotificationAsync)
+ .mockResolvedValueOnce('scheduled-1')
+ .mockResolvedValueOnce('scheduled-2')
+ vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
+ let onEvent: ((data: unknown) => void) | null = null
+ const client = {
+ subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
+ onEvent = callback
+ return vi.fn()
+ }),
+ getState: vi.fn(() => 'connected'),
+ sendRequest: vi.fn()
+ } as unknown as RpcClient
+
+ subscribeToDesktopNotifications(client, 'host-1')
+ onEvent?.({
+ type: 'notification',
+ source: 'agent-task-complete',
+ title: 'Done',
+ body: 'Finished.',
+ worktreeId: 'repo::/tmp/worktree',
+ notificationId: 'agent:one'
+ })
+ await flushAsync()
+ onEvent?.({
+ type: 'notification',
+ source: 'agent-task-complete',
+ title: 'Done again',
+ body: 'Finished again.',
+ notificationId: 'agent:one'
+ })
+ await flushAsync()
+ expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2)
+ onEvent?.({ type: 'dismiss', notificationId: 'agent:one' })
+ await flushAsync()
+
+ expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2)
+ expect(Notifications.scheduleNotificationAsync).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ content: expect.objectContaining({
+ data: expect.objectContaining({
+ hostId: 'host-1',
+ notificationId: 'agent:one',
+ worktreeId: 'repo::/tmp/worktree'
+ })
+ })
+ })
+ )
+ expect(Notifications.dismissNotificationAsync).toHaveBeenNthCalledWith(1, 'scheduled-1')
+ expect(Notifications.dismissNotificationAsync).toHaveBeenNthCalledWith(2, 'scheduled-2')
+ })
+
+ it('dedupes concurrent notification events with the same desktop notification id', async () => {
+ vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
+ vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
+ status: 'granted',
+ canAskAgain: true
+ } as never)
+ vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
+ let onEvent: ((data: unknown) => void) | null = null
+ const client = {
+ subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
+ onEvent = callback
+ return vi.fn()
+ }),
+ getState: vi.fn(() => 'connected'),
+ sendRequest: vi.fn()
+ } as unknown as RpcClient
+
+ subscribeToDesktopNotifications(client, 'host-concurrent')
+ onEvent?.({
+ type: 'notification',
+ source: 'agent-task-complete',
+ title: 'Done',
+ body: 'Finished.',
+ notificationId: 'agent:concurrent'
+ })
+ onEvent?.({
+ type: 'notification',
+ source: 'agent-task-complete',
+ title: 'Done',
+ body: 'Finished.',
+ notificationId: 'agent:concurrent'
+ })
+ await flushAsync()
+
+ expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(1)
+ })
+})
+
+it('filters before cooldown and retains the existing banner when a later burst is suppressed', async () => {
+ vi.clearAllMocks()
+ vi.mocked(AsyncStorage.getItem).mockResolvedValue(
+ JSON.stringify({ followDesktop: false, terminalBell: false })
+ )
+ vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
+ vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
+ status: 'granted',
+ canAskAgain: true
+ } as never)
+ vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('cooldown-banner')
+ const event = {
+ type: 'notification' as const,
+ title: 'Done',
+ body: '',
+ worktreeId: 'folder',
+ notificationId: 'cooldown-event',
+ emittedAt: 10000
+ }
+ await showLocalNotification({ ...event, source: 'terminal-bell' }, 'cooldown-host')
+ await showLocalNotification(
+ { ...event, source: 'agent-task-complete', agentState: 'done', emittedAt: 10250 },
+ 'cooldown-host'
+ )
+ await showLocalNotification(
+ { ...event, source: 'agent-task-complete', agentState: 'done', emittedAt: 10500 },
+ 'cooldown-host'
+ )
+ expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(1)
+ expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled()
+})
diff --git a/mobile/src/notifications/notification-local-dismissal.test.ts b/mobile/src/notifications/notification-local-dismissal.test.ts
new file mode 100644
index 00000000000..74a700d2f0d
--- /dev/null
+++ b/mobile/src/notifications/notification-local-dismissal.test.ts
@@ -0,0 +1,251 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import * as Notifications from 'expo-notifications'
+import { Platform } from 'react-native'
+import {
+ setScheduledNotificationsMaxForTests,
+ subscribeToDesktopNotifications
+} from './mobile-notifications'
+import type { RpcClient } from '../transport/rpc-client'
+import { loadPushNotificationsEnabled } from '../storage/preferences'
+import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
+
+vi.mock('expo-notifications', () => ({
+ AndroidImportance: { HIGH: 'high' },
+ setNotificationChannelAsync: vi.fn(),
+ getPresentedNotificationsAsync: vi.fn(async () => []),
+ getPermissionsAsync: vi.fn(),
+ requestPermissionsAsync: vi.fn(),
+ scheduleNotificationAsync: vi.fn(),
+ dismissNotificationAsync: vi.fn()
+}))
+
+vi.mock('react-native', () => ({
+ AppState: { currentState: 'background' },
+ Platform: { OS: 'ios', Version: 18 }
+}))
+
+// The reconnect catch-up reads the tray to learn which pushes the OS already showed,
+// and mapping those to this host needs the catalog, whose real module pulls the
+// native keychain. No push is presented in these tests, so an empty catalog is enough.
+vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn(async () => []) }))
+
+// Why: mobile-notifications now persists the catch-up watermark to
+// AsyncStorage. The package isn't resolvable in the node test env (other
+// mobile tests mock it the same way), so we provide a no-op mock.
+vi.mock('@react-native-async-storage/async-storage', () => ({
+ default: {
+ getItem: vi.fn(async () => null),
+ setItem: vi.fn(async () => undefined)
+ }
+}))
+
+vi.mock('../storage/preferences', () => ({
+ loadRemotePushEnabled: vi.fn(async () => false),
+ loadPushNotificationsEnabled: vi.fn()
+}))
+
+beforeEach(() => {
+ Object.assign(Platform, { OS: 'ios', Version: 18 })
+ // Why (#8591): the reconnect watermark/seen-set now live per host at module
+ // scope so they survive the app's unsubscribe-on-disconnect. Reset between
+ // tests so each case starts from a genuine cold open.
+ resetHostNotificationSessionsForTests()
+})
+
+describe('subscribeToDesktopNotifications', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // Why the macrotask and not N microtask ticks (#8591): deliveries now run through
+ // the per-host serialization queue, so a delivery is several more `await` hops deep
+ // than it used to be and a fixed tick count silently under-drains. Yielding to the
+ // macrotask queue drains whatever depth the chain happens to have.
+ function flushAsync(): Promise {
+ return new Promise((resolve) => {
+ setTimeout(resolve, 0)
+ })
+ }
+
+ function makeDeferred(): { promise: Promise; resolve: (value: T) => void } {
+ let resolve!: (value: T) => void
+ const promise = new Promise((next) => {
+ resolve = next
+ })
+ return { promise, resolve }
+ }
+
+ it('dismisses a notification when dismiss arrives while scheduling is pending', async () => {
+ vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
+ vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
+ status: 'granted',
+ canAskAgain: true
+ } as never)
+ let resolveSchedule!: (identifier: string) => void
+ vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveSchedule = resolve
+ })
+ )
+ vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
+ let onEvent: ((data: unknown) => void) | null = null
+ const client = {
+ subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
+ onEvent = callback
+ return vi.fn()
+ }),
+ getState: vi.fn(() => 'connected'),
+ sendRequest: vi.fn()
+ } as unknown as RpcClient
+
+ subscribeToDesktopNotifications(client, 'host-dismiss-race')
+ onEvent?.({
+ type: 'notification',
+ source: 'agent-task-complete',
+ title: 'Done',
+ body: 'Finished.',
+ notificationId: 'agent:pending'
+ })
+ await flushAsync()
+ onEvent?.({ type: 'dismiss', notificationId: 'agent:pending' })
+ resolveSchedule('scheduled-pending')
+ await flushAsync()
+
+ expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-pending')
+ })
+
+ it('does not carry a failed pending dismiss into a future schedule', async () => {
+ const secondEnabled = makeDeferred()
+ vi.mocked(loadPushNotificationsEnabled)
+ .mockResolvedValueOnce(true)
+ .mockReturnValueOnce(secondEnabled.promise)
+ .mockResolvedValueOnce(true)
+ vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
+ status: 'granted',
+ canAskAgain: true
+ } as never)
+ vi.mocked(Notifications.scheduleNotificationAsync)
+ .mockResolvedValueOnce('scheduled-1')
+ .mockResolvedValueOnce('scheduled-2')
+ vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
+ let onEvent: ((data: unknown) => void) | null = null
+ const client = {
+ subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
+ onEvent = callback
+ return vi.fn()
+ }),
+ getState: vi.fn(() => 'connected'),
+ sendRequest: vi.fn()
+ } as unknown as RpcClient
+
+ subscribeToDesktopNotifications(client, 'host-dismiss-failed-replacement')
+ onEvent?.({
+ type: 'notification',
+ source: 'agent-task-complete',
+ title: 'Done',
+ body: 'Finished.',
+ notificationId: 'agent:stale-dismiss'
+ })
+ await flushAsync()
+ onEvent?.({
+ type: 'notification',
+ source: 'agent-task-complete',
+ title: 'Done again',
+ body: 'Finished again.',
+ notificationId: 'agent:stale-dismiss'
+ })
+ await flushAsync()
+ onEvent?.({ type: 'dismiss', notificationId: 'agent:stale-dismiss' })
+ secondEnabled.resolve(false)
+ await flushAsync()
+
+ onEvent?.({
+ type: 'notification',
+ source: 'agent-task-complete',
+ title: 'Done later',
+ body: 'Finished later.',
+ notificationId: 'agent:stale-dismiss'
+ })
+ await flushAsync()
+
+ expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2)
+ expect(Notifications.dismissNotificationAsync).toHaveBeenCalledTimes(1)
+ expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-1')
+ })
+
+ it('treats unknown dismiss events as no-ops', async () => {
+ vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
+ let onEvent: ((data: unknown) => void) | null = null
+ const client = {
+ subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
+ onEvent = callback
+ return vi.fn()
+ }),
+ getState: vi.fn(() => 'connected'),
+ sendRequest: vi.fn()
+ } as unknown as RpcClient
+
+ subscribeToDesktopNotifications(client, 'host-unknown')
+ onEvent?.({ type: 'dismiss', notificationId: 'agent:missing' })
+ await flushAsync()
+
+ expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled()
+ })
+
+ // Why: notificationId is unique per completion, so the map grew unbounded when
+ // the desktop never sent a dismiss (the remote-mobile case). It is now capped.
+ it('evicts the oldest scheduled entry once the cap is exceeded', async () => {
+ setScheduledNotificationsMaxForTests(1)
+ try {
+ vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
+ vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
+ status: 'granted',
+ canAskAgain: true
+ } as never)
+ vi.mocked(Notifications.scheduleNotificationAsync)
+ .mockResolvedValueOnce('scheduled-old')
+ .mockResolvedValueOnce('scheduled-new')
+ vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
+ let onEvent: ((data: unknown) => void) | null = null
+ const client = {
+ subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
+ onEvent = callback
+ return vi.fn()
+ }),
+ getState: vi.fn(() => 'connected'),
+ sendRequest: vi.fn()
+ } as unknown as RpcClient
+
+ subscribeToDesktopNotifications(client, 'host-1')
+ onEvent?.({
+ type: 'notification',
+ source: 'agent-task-complete',
+ title: 't',
+ body: 'b',
+ notificationId: 'agent:old'
+ })
+ await flushAsync()
+ onEvent?.({
+ type: 'notification',
+ source: 'agent-task-complete',
+ title: 't',
+ body: 'b',
+ notificationId: 'agent:new'
+ })
+ await flushAsync()
+
+ // The older entry was evicted by the cap: dismissing it is a no-op...
+ onEvent?.({ type: 'dismiss', notificationId: 'agent:old' })
+ await flushAsync()
+ expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalledWith('scheduled-old')
+
+ // ...while the most-recent entry is retained and still dismissable.
+ onEvent?.({ type: 'dismiss', notificationId: 'agent:new' })
+ await flushAsync()
+ expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-new')
+ } finally {
+ setScheduledNotificationsMaxForTests()
+ }
+ })
+})
diff --git a/mobile/src/notifications/notification-reconnect-teardown.test.ts b/mobile/src/notifications/notification-reconnect-teardown.test.ts
index a5e7433bf0f..a291982245b 100644
--- a/mobile/src/notifications/notification-reconnect-teardown.test.ts
+++ b/mobile/src/notifications/notification-reconnect-teardown.test.ts
@@ -9,6 +9,7 @@ import { loadPushNotificationsEnabled } from '../storage/preferences'
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
setNotificationChannelAsync: vi.fn(),
+ getPresentedNotificationsAsync: vi.fn(async () => []),
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
@@ -16,9 +17,15 @@ vi.mock('expo-notifications', () => ({
}))
vi.mock('react-native', () => ({
+ AppState: { currentState: 'background' },
Platform: { OS: 'ios', Version: 18 }
}))
+// The reconnect catch-up reads the tray to learn which pushes the OS already showed,
+// and mapping those to this host needs the catalog, whose real module pulls the
+// native keychain. No push is presented in these tests, so an empty catalog is enough.
+vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn(async () => []) }))
+
// In-memory AsyncStorage so the persisted watermark survives across the
// subscribe/unsubscribe cycles this test exercises (the real device behaviour).
const storage = new Map()
@@ -32,6 +39,7 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
}))
vi.mock('../storage/preferences', () => ({
+ loadRemotePushEnabled: vi.fn(async () => false),
loadPushNotificationsEnabled: vi.fn()
}))
@@ -46,7 +54,7 @@ function flushAsync(): Promise {
// scratch on the next 'connected'.
function makeHostClient() {
let onData: ((data: unknown) => void) | null = null
- const getMissedCalls: { lastSeenSeq: number }[] = []
+ const getMissedCalls: { includeDesktopSuppressed: true; lastSeenSeq: number }[] = []
const client = {
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
onData = cb
@@ -57,7 +65,7 @@ function makeHostClient() {
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async (method: string, params: unknown = {}) => {
if (method === 'notifications.getMissedSince') {
- getMissedCalls.push(params as { lastSeenSeq: number })
+ getMissedCalls.push(params as { includeDesktopSuppressed: true; lastSeenSeq: number })
return { ok: true, result: { notifications: missedQueue } } as never
}
return { ok: true, result: undefined } as never
@@ -100,6 +108,7 @@ describe('#8591 reconnect catch-up under the real app teardown lifecycle', () =>
await flushAsync()
host.onData?.({
type: 'notification',
+ source: 'agent-task-complete',
title: 'live',
body: 'b',
notificationId: 'agent:live',
@@ -117,6 +126,7 @@ describe('#8591 reconnect catch-up under the real app teardown lifecycle', () =>
host.setMissed([
{
type: 'notification',
+ source: 'agent-task-complete',
title: 'missed-8',
body: 'b',
notificationId: 'agent:m8',
@@ -124,6 +134,7 @@ describe('#8591 reconnect catch-up under the real app teardown lifecycle', () =>
},
{
type: 'notification',
+ source: 'agent-task-complete',
title: 'missed-9',
body: 'b',
notificationId: 'agent:m9',
@@ -139,7 +150,7 @@ describe('#8591 reconnect catch-up under the real app teardown lifecycle', () =>
// The user must be told about seq 8 and 9. Nothing else can deliver them:
// the desktop only fans out live, so this catch-up is the only path.
expect(host.getMissedCalls).toHaveLength(1)
- expect(host.getMissedCalls[0]).toEqual({ lastSeenSeq: 7 })
+ expect(host.getMissedCalls[0]).toEqual({ includeDesktopSuppressed: true, lastSeenSeq: 7 })
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((c) => (c[0] as { content: { title: string } }).content.title)
@@ -160,6 +171,7 @@ describe('#8591 reconnect catch-up under the real app teardown lifecycle', () =>
await flushAsync()
host.onData?.({
type: 'notification',
+ source: 'agent-task-complete',
title: 'live-7',
body: 'b',
notificationId: 'agent:seven',
@@ -174,6 +186,7 @@ describe('#8591 reconnect catch-up under the real app teardown lifecycle', () =>
host.setMissed([
{
type: 'notification',
+ source: 'agent-task-complete',
title: 'live-7',
body: 'b',
notificationId: 'agent:seven',
@@ -181,6 +194,7 @@ describe('#8591 reconnect catch-up under the real app teardown lifecycle', () =>
},
{
type: 'notification',
+ source: 'agent-task-complete',
title: 'missed-8',
body: 'b',
notificationId: 'agent:m8',
diff --git a/mobile/src/notifications/notification-reopen-push-duplicate.test.ts b/mobile/src/notifications/notification-reopen-push-duplicate.test.ts
new file mode 100644
index 00000000000..e6a0bd9287f
--- /dev/null
+++ b/mobile/src/notifications/notification-reopen-push-duplicate.test.ts
@@ -0,0 +1,204 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import * as Notifications from 'expo-notifications'
+import { sha256 } from '@noble/hashes/sha256'
+import { loadHostCatalog } from '../transport/host-store'
+import type { HostCatalogEntry } from '../transport/types'
+import type { RpcClient } from '../transport/rpc-client'
+import { loadPushNotificationsEnabled } from '../storage/preferences'
+import { subscribeToDesktopNotifications } from './mobile-notifications'
+import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
+
+// Why this file exists: a push the OS drew while Orca was closed never runs through
+// the foreground handler, so nothing marks it seen. The reconnect catch-up then
+// replays the same event and the user gets a second banner for it.
+
+vi.mock('expo-notifications', () => ({
+ AndroidImportance: { HIGH: 'high' },
+ setNotificationChannelAsync: vi.fn(),
+ getPresentedNotificationsAsync: vi.fn(async () => []),
+ getPermissionsAsync: vi.fn(),
+ requestPermissionsAsync: vi.fn(),
+ scheduleNotificationAsync: vi.fn(),
+ dismissNotificationAsync: vi.fn()
+}))
+
+vi.mock('react-native', () => ({
+ AppState: { currentState: 'background' },
+ Platform: { OS: 'ios', Version: 18 }
+}))
+
+vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn() }))
+
+const WATERMARK_KEY = 'orca:mobileNotificationsWatermark:host-1'
+const storage = new Map()
+
+vi.mock('@react-native-async-storage/async-storage', () => ({
+ default: {
+ getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
+ setItem: vi.fn(async (key: string, value: string) => {
+ storage.set(key, value)
+ })
+ }
+}))
+
+vi.mock('../storage/preferences', () => ({
+ loadRemotePushEnabled: vi.fn(async () => false),
+ loadPushNotificationsEnabled: vi.fn()
+}))
+
+const publicKey = Uint8Array.from({ length: 32 }, (_, index) => index)
+const publicKeyB64 = Buffer.from(publicKey).toString('base64')
+const hostFingerprint = Buffer.from(sha256(publicKey)).toString('base64url').slice(0, 16)
+
+function flushAsync(): Promise {
+ return new Promise((resolve) => {
+ setTimeout(resolve, 10)
+ })
+}
+
+function presentTray(entries: readonly Record[]): void {
+ vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue(
+ entries.map((orca, index) => ({
+ request: {
+ identifier: `tray-${index}`,
+ content: { data: null },
+ trigger: { type: 'push', payload: { orca } }
+ }
+ })) as never
+ )
+}
+
+function shownTitles(): string[] {
+ return vi
+ .mocked(Notifications.scheduleNotificationAsync)
+ .mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
+}
+
+function persistedSeq(): number {
+ return (JSON.parse(storage.get(WATERMARK_KEY) ?? '{}') as { seq?: number }).seq ?? 0
+}
+
+/** A catch-up that replays seq 6 and 7 for host-1. */
+function catchUpClient(): { client: RpcClient; ready: () => void } {
+ let onData: ((data: unknown) => void) | null = null
+ const client = {
+ subscribe: vi.fn((_method: string, _params: unknown, callback: (data: unknown) => void) => {
+ onData = callback
+ return vi.fn()
+ }),
+ getState: vi.fn(() => 'connected'),
+ sendRequest: vi.fn(async (method: string) => {
+ if (method === 'notifications.getMissedSince') {
+ return {
+ ok: true,
+ result: {
+ notifications: [
+ {
+ type: 'notification',
+ source: 'agent-task-complete',
+ title: 'm6',
+ body: 'b',
+ notificationId: 'a:6',
+ notificationSeq: 6
+ },
+ {
+ type: 'notification',
+ source: 'agent-task-complete',
+ title: 'm7',
+ body: 'b',
+ notificationId: 'a:7',
+ notificationSeq: 7
+ }
+ ]
+ }
+ } as never
+ }
+ return { ok: true, result: undefined } as never
+ })
+ } as unknown as RpcClient
+ return {
+ client,
+ ready: () => onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
+ }
+}
+
+async function reopenWithTray(): Promise {
+ storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
+ const { client, ready } = catchUpClient()
+ subscribeToDesktopNotifications(client, 'host-1')
+ ready()
+ await flushAsync()
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ storage.clear()
+ resetHostNotificationSessionsForTests()
+ vi.mocked(loadHostCatalog).mockResolvedValue([
+ { id: 'host-1', publicKeyB64 }
+ ] as unknown as HostCatalogEntry[])
+ vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
+ vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
+ status: 'granted',
+ canAskAgain: true
+ } as never)
+ vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
+ vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
+ vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([])
+})
+
+describe('reopen after a push the OS showed while Orca was closed', () => {
+ it('replays only the events still missing from the tray', async () => {
+ presentTray([
+ { hostFingerprint, notificationId: 'a:6', notificationSeq: 6, notificationEpoch: 'epoch-1' }
+ ])
+
+ await reopenWithTray()
+
+ expect(shownTitles()).toEqual(['m7'])
+ })
+
+ it('leaves the watermark to the replay rather than jumping it to the push seq', async () => {
+ presentTray([
+ { hostFingerprint, notificationId: 'a:9', notificationSeq: 9, notificationEpoch: 'epoch-1' }
+ ])
+
+ await reopenWithTray()
+
+ // Seq 9 in the tray says one event was shown, not that 6..8 were; advancing past
+ // them would make the desktop cut them out of every later catch-up.
+ expect(shownTitles()).toEqual(['m6', 'm7'])
+ expect(persistedSeq()).toBe(7)
+ })
+
+ it('still replays an event a coalesced summary only counted', async () => {
+ presentTray([
+ {
+ hostFingerprint,
+ notificationId: 'a:6',
+ notificationSeq: 6,
+ notificationEpoch: 'epoch-1',
+ coalescedCount: 3
+ }
+ ])
+
+ await reopenWithTray()
+
+ expect(shownTitles()).toEqual(['m6', 'm7'])
+ })
+
+ it('ignores a tray entry pushed for a different paired host', async () => {
+ presentTray([
+ {
+ hostFingerprint: '0123456789abcdef',
+ notificationId: 'a:6',
+ notificationSeq: 6,
+ notificationEpoch: 'epoch-1'
+ }
+ ])
+
+ await reopenWithTray()
+
+ expect(shownTitles()).toEqual(['m6', 'm7'])
+ })
+})
diff --git a/mobile/src/notifications/notification-viewing-policy.ts b/mobile/src/notifications/notification-viewing-policy.ts
new file mode 100644
index 00000000000..c54a04fd695
--- /dev/null
+++ b/mobile/src/notifications/notification-viewing-policy.ts
@@ -0,0 +1,30 @@
+import { AppState } from 'react-native'
+import {
+ allowsMobileNotification,
+ type MobileNotificationPolicyEvent
+} from '../../../src/shared/mobile-notification-policy'
+import {
+ loadNotificationDeliveryPreferences,
+ notificationPreferencesFilter
+} from './notification-delivery-preferences'
+
+let viewing: { hostId: string; worktreeId: string } | null = null
+export function setNotificationViewingWorkspace(value: typeof viewing): void {
+ viewing = value
+}
+
+export async function allowsLocalNotification(
+ event: MobileNotificationPolicyEvent & { worktreeId?: string },
+ hostId: string
+): Promise {
+ const preferences = await loadNotificationDeliveryPreferences()
+ if (!allowsMobileNotification(notificationPreferencesFilter(preferences), event)) {
+ return false
+ }
+ return !(
+ preferences.suppressWhileViewing &&
+ AppState.currentState === 'active' &&
+ viewing?.hostId === hostId &&
+ viewing.worktreeId === event.worktreeId
+ )
+}
diff --git a/mobile/src/notifications/notification-watermark-seed-race.test.ts b/mobile/src/notifications/notification-watermark-seed-race.test.ts
index 742f0711982..12efb88e5d0 100644
--- a/mobile/src/notifications/notification-watermark-seed-race.test.ts
+++ b/mobile/src/notifications/notification-watermark-seed-race.test.ts
@@ -15,6 +15,7 @@ import { loadPushNotificationsEnabled } from '../storage/preferences'
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
setNotificationChannelAsync: vi.fn(),
+ getPresentedNotificationsAsync: vi.fn(async () => []),
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
@@ -22,9 +23,15 @@ vi.mock('expo-notifications', () => ({
}))
vi.mock('react-native', () => ({
+ AppState: { currentState: 'background' },
Platform: { OS: 'ios', Version: 18 }
}))
+// The reconnect catch-up reads the tray to learn which pushes the OS already showed,
+// and mapping those to this host needs the catalog, whose real module pulls the
+// native keychain. No push is presented in these tests, so an empty catalog is enough.
+vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn(async () => []) }))
+
// A storage whose reads can be held open, so a live event can be injected into the
// exact window a real cold open has: subscription up, persisted watermark not yet read.
const storage = new Map()
@@ -51,6 +58,7 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
}))
vi.mock('../storage/preferences', () => ({
+ loadRemotePushEnabled: vi.fn(async () => false),
loadPushNotificationsEnabled: vi.fn()
}))
@@ -70,7 +78,8 @@ function releaseReads(): void {
function makeHostClient() {
let onData: ((data: unknown) => void) | null = null
- const getMissedCalls: { lastSeenSeq: number; epoch?: string }[] = []
+ const getMissedCalls: { includeDesktopSuppressed: true; lastSeenSeq: number; epoch?: string }[] =
+ []
const client = {
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
onData = cb
@@ -81,7 +90,9 @@ function makeHostClient() {
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async (method: string, params: unknown = {}) => {
if (method === 'notifications.getMissedSince') {
- getMissedCalls.push(params as { lastSeenSeq: number; epoch?: string })
+ getMissedCalls.push(
+ params as { includeDesktopSuppressed: true; lastSeenSeq: number; epoch?: string }
+ )
return { ok: true, result: { notifications: [] } } as never
}
return { ok: true, result: undefined } as never
@@ -128,6 +139,7 @@ describe('#8591 watermark seeding races a cold open', () => {
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-a' })
host.onData?.({
type: 'notification',
+ source: 'agent-task-complete',
title: 'live-12',
body: 'b',
notificationId: 'agent:live',
@@ -142,7 +154,9 @@ describe('#8591 watermark seeding races a cold open', () => {
releaseReads()
await flushAsync()
- expect(host.getMissedCalls).toEqual([{ lastSeenSeq: 5, epoch: 'epoch-a' }])
+ expect(host.getMissedCalls).toEqual([
+ { includeDesktopSuppressed: true, lastSeenSeq: 5, epoch: 'epoch-a' }
+ ])
})
it('treats a zeroed-but-present watermark as a returning device, not a first pairing', async () => {
@@ -156,7 +170,9 @@ describe('#8591 watermark seeding races a cold open', () => {
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-a' })
await flushAsync()
- expect(host.getMissedCalls).toEqual([{ lastSeenSeq: 0, epoch: 'epoch-a' }])
+ expect(host.getMissedCalls).toEqual([
+ { includeDesktopSuppressed: true, lastSeenSeq: 0, epoch: 'epoch-a' }
+ ])
})
it('does not catch up on a first-ever pairing', async () => {
diff --git a/mobile/src/notifications/push-host-fingerprint.test.ts b/mobile/src/notifications/push-host-fingerprint.test.ts
new file mode 100644
index 00000000000..2fc5b44dba1
--- /dev/null
+++ b/mobile/src/notifications/push-host-fingerprint.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from 'vitest'
+import { sha256 } from '@noble/hashes/sha256'
+import { deriveHostFingerprint, resolveHostIdForFingerprint } from './push-host-fingerprint'
+
+// Why Buffer here: it computes the same value through a completely different
+// base64 path than the module's btoa/replace, so the vector is a real cross-check
+// of the derivation the desktop and gateway independently perform.
+function expectedFingerprint(publicKey: Uint8Array): string {
+ return Buffer.from(sha256(publicKey)).toString('base64url').slice(0, 16)
+}
+
+const publicKey = Uint8Array.from({ length: 32 }, (_, index) => index)
+const publicKeyB64 = Buffer.from(publicKey).toString('base64')
+
+describe('deriveHostFingerprint', () => {
+ it('matches base64url(sha256(publicKey)) truncated to 16 chars', () => {
+ const fingerprint = deriveHostFingerprint(publicKeyB64)
+
+ expect(fingerprint).toBe(expectedFingerprint(publicKey))
+ expect(fingerprint).toHaveLength(16)
+ })
+
+ it('produces url-safe characters only, so a fingerprint survives a JSON payload', () => {
+ // 0xff bytes are what push '+' and '/' into a standard base64 digest.
+ const dense = new Uint8Array(32).fill(0xff)
+ const fingerprint = deriveHostFingerprint(Buffer.from(dense).toString('base64'))
+
+ expect(fingerprint).toBe(expectedFingerprint(dense))
+ expect(fingerprint).toMatch(/^[A-Za-z0-9_-]{16}$/)
+ })
+
+ it.each([
+ ['a key of the wrong length', Buffer.from(new Uint8Array(16)).toString('base64')],
+ ['text that is not base64 at all', '!!!not base64!!!'],
+ ['an empty key', '']
+ ])('returns null for %s', (_label, value) => {
+ expect(deriveHostFingerprint(value)).toBeNull()
+ })
+})
+
+describe('resolveHostIdForFingerprint', () => {
+ const other = Uint8Array.from({ length: 32 }, (_, index) => index + 1)
+ const hosts = [
+ { id: 'host-corrupt', publicKeyB64: 'not-a-key' },
+ { id: 'host-other', publicKeyB64: Buffer.from(other).toString('base64') },
+ { id: 'host-1', publicKeyB64 }
+ ]
+
+ it('maps a push fingerprint back to the paired host id', () => {
+ expect(resolveHostIdForFingerprint(expectedFingerprint(publicKey), hosts)).toBe('host-1')
+ })
+
+ it('returns null for a fingerprint no paired host derives', () => {
+ expect(resolveHostIdForFingerprint('0123456789abcdef', hosts)).toBeNull()
+ })
+
+ it('rejects a fingerprint of the wrong length before hashing anything', () => {
+ expect(
+ resolveHostIdForFingerprint(expectedFingerprint(publicKey).slice(0, 8), hosts)
+ ).toBeNull()
+ })
+})
diff --git a/mobile/src/notifications/push-host-fingerprint.ts b/mobile/src/notifications/push-host-fingerprint.ts
new file mode 100644
index 00000000000..3aa8b739fba
--- /dev/null
+++ b/mobile/src/notifications/push-host-fingerprint.ts
@@ -0,0 +1,58 @@
+import { sha256 } from '@noble/hashes/sha256'
+
+// Why: a push arrives from the gateway, so it can only name the host by something
+// both sides derive independently — base64url(sha256(hostPublicKey)) truncated to
+// 16 chars, identical to deriveRelayHostId in
+// src/main/runtime/relay/relay-http-client.ts. The phone maps it back to its own
+// hostId by re-deriving over each stored host's publicKeyB64.
+//
+// Base64 is inlined rather than imported (same call as mobile-relay-credential-hash.ts):
+// the only shared encoders live in modules that drag in tweetnacl, expo-crypto, or
+// the host store, none of which a pure derivation should need.
+
+const HOST_FINGERPRINT_LENGTH = 16
+
+function decodeBase64(value: string): Uint8Array | null {
+ try {
+ const binary = atob(value)
+ const bytes = new Uint8Array(binary.length)
+ for (let index = 0; index < binary.length; index++) {
+ bytes[index] = binary.charCodeAt(index)
+ }
+ return bytes
+ } catch {
+ return null
+ }
+}
+
+function encodeBase64Url(bytes: Uint8Array): string {
+ let binary = ''
+ for (const byte of bytes) {
+ binary += String.fromCharCode(byte)
+ }
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
+}
+
+/** Null when the stored key is unreadable, so a corrupt host entry can't shadow a real match. */
+export function deriveHostFingerprint(publicKeyB64: string): string | null {
+ const publicKey = decodeBase64(publicKeyB64)
+ if (!publicKey || publicKey.length !== 32) {
+ return null
+ }
+ return encodeBase64Url(sha256(publicKey)).slice(0, HOST_FINGERPRINT_LENGTH)
+}
+
+export function resolveHostIdForFingerprint(
+ fingerprint: string,
+ hosts: readonly { readonly id: string; readonly publicKeyB64: string }[]
+): string | null {
+ if (fingerprint.length !== HOST_FINGERPRINT_LENGTH) {
+ return null
+ }
+ for (const host of hosts) {
+ if (deriveHostFingerprint(host.publicKeyB64) === fingerprint) {
+ return host.id
+ }
+ }
+ return null
+}
diff --git a/mobile/src/notifications/push-payload.ts b/mobile/src/notifications/push-payload.ts
new file mode 100644
index 00000000000..8de0243a63f
--- /dev/null
+++ b/mobile/src/notifications/push-payload.ts
@@ -0,0 +1,47 @@
+// Why two shapes: APNs nests Orca's fields under `orca` beside `aps`, while FCM
+// carries them flat in `data` as strings. Both reach JS as the notification's
+// `content.data`, so the reader accepts either and coerces the numeric fields.
+export type OrcaPushPayload = {
+ readonly hostFingerprint: string
+ readonly notificationId?: string
+ readonly notificationSeq?: number
+ readonly notificationEpoch?: string
+ readonly worktreeId?: string
+ readonly source?: string
+ readonly agentState?: string
+ // Present only on a gateway summary standing in for N events; see the coalescing
+ // window in docs/reference/mobile-push-contract.md.
+ readonly coalescedCount?: number
+}
+
+function readString(value: unknown): string | undefined {
+ return typeof value === 'string' && value.length > 0 ? value : undefined
+}
+
+function readSeq(value: unknown): number | undefined {
+ const raw = typeof value === 'number' ? value : Number(readString(value))
+ return Number.isFinite(raw) ? raw : undefined
+}
+
+export function readOrcaPushPayload(data: unknown): OrcaPushPayload | null {
+ if (!data || typeof data !== 'object') {
+ return null
+ }
+ const nested = (data as { orca?: unknown }).orca
+ const record = (nested && typeof nested === 'object' ? nested : data) as Record
+ // The fingerprint is what makes this a gateway push; locally scheduled data never has one.
+ const hostFingerprint = readString(record.hostFingerprint)
+ if (!hostFingerprint) {
+ return null
+ }
+ return {
+ hostFingerprint,
+ notificationId: readString(record.notificationId),
+ notificationSeq: readSeq(record.notificationSeq),
+ notificationEpoch: readString(record.notificationEpoch),
+ worktreeId: readString(record.worktreeId),
+ source: readString(record.source),
+ agentState: readString(record.agentState),
+ coalescedCount: readSeq(record.coalescedCount)
+ }
+}
diff --git a/mobile/src/notifications/push-preference-update.test.ts b/mobile/src/notifications/push-preference-update.test.ts
new file mode 100644
index 00000000000..1e1426fef93
--- /dev/null
+++ b/mobile/src/notifications/push-preference-update.test.ts
@@ -0,0 +1,75 @@
+import { beforeEach, expect, it, vi } from 'vitest'
+import {
+ attachPushRegistration,
+ resetPushRegistrationForTests,
+ setNotificationDeliveryPreferences,
+ NOTIFICATIONS_REMOTE_PUSH_CAPABILITY
+} from './push-registration'
+import { DEFAULT_NOTIFICATION_DELIVERY } from './notification-delivery-preferences'
+
+const storage = new Map()
+vi.mock('@react-native-async-storage/async-storage', () => ({
+ default: {
+ getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
+ setItem: vi.fn(async (key: string, value: string) => {
+ storage.set(key, value)
+ })
+ }
+}))
+vi.mock('./push-token', () => ({
+ getDevicePushToken: vi.fn(async () => ({
+ platform: 'ios',
+ token: 'a'.repeat(64),
+ apnsEnvironment: 'sandbox'
+ })),
+ addPushTokenListener: vi.fn()
+}))
+
+beforeEach(() => {
+ resetPushRegistrationForTests()
+ storage.clear()
+ storage.set('orca:remotePushEnabled', 'true')
+})
+
+it('replaces an in-flight old registration with the latest event and sound preferences', async () => {
+ const calls: { method: string; params: unknown }[] = []
+ let finishFirst: ((value: unknown) => void) | undefined
+ const client = {
+ sendRequest: vi.fn(async (method: string, params?: unknown) => {
+ calls.push({ method, params })
+ if (method === 'status.get') {
+ return { ok: true, result: { capabilities: [NOTIFICATIONS_REMOTE_PUSH_CAPABILITY] } }
+ }
+ if (method === 'notifications.registerPush') {
+ if (!finishFirst) {
+ return new Promise((resolve) => {
+ finishFirst = resolve
+ })
+ }
+ return { ok: true, result: { registered: true, registrationId: 'new' } }
+ }
+ return { ok: true, result: { unregistered: true } }
+ })
+ }
+ const detach = attachPushRegistration('host', client as never)
+ await vi.waitFor(() => expect(finishFirst).toBeDefined())
+ const update = setNotificationDeliveryPreferences({
+ ...DEFAULT_NOTIFICATION_DELIVERY,
+ followDesktop: false,
+ terminalBell: false,
+ sound: false
+ })
+ finishFirst!({ ok: true, result: { registered: true, registrationId: 'old' } })
+ await update
+ await vi.waitFor(() =>
+ expect(
+ calls.filter((call) => call.method === 'notifications.registerPush').length
+ ).toBeGreaterThan(1)
+ )
+ const latest = calls.findLast((call) => call.method === 'notifications.registerPush')
+ expect(latest?.params).toMatchObject({
+ filter: { followDesktop: false, sound: false, sources: ['agent-task-complete', 'plugin'] }
+ })
+ expect(calls.some((call) => call.method === 'notifications.unregisterPush')).toBe(true)
+ detach()
+})
diff --git a/mobile/src/notifications/push-receive.test.ts b/mobile/src/notifications/push-receive.test.ts
new file mode 100644
index 00000000000..ddfc2708e21
--- /dev/null
+++ b/mobile/src/notifications/push-receive.test.ts
@@ -0,0 +1,281 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import AsyncStorage from '@react-native-async-storage/async-storage'
+import { sha256 } from '@noble/hashes/sha256'
+import { loadHostCatalog } from '../transport/host-store'
+import type { HostCatalogEntry } from '../transport/types'
+import { getNotificationNavigationTarget } from './notification-routing'
+import {
+ getHostNotificationSession,
+ resetHostNotificationSessionsForTests
+} from './notification-reconnect-catchup'
+import {
+ isRemotePushTrigger,
+ pushNotificationRouteData,
+ shouldSuppressForegroundPush
+} from './push-receive'
+
+vi.mock('react-native', () => ({ AppState: { currentState: 'background' } }))
+
+vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn() }))
+
+const storage = new Map()
+
+vi.mock('@react-native-async-storage/async-storage', () => ({
+ default: {
+ getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
+ setItem: vi.fn(async (key: string, value: string) => {
+ storage.set(key, value)
+ }),
+ removeItem: vi.fn(async () => undefined)
+ }
+}))
+
+const publicKey = Uint8Array.from({ length: 32 }, (_, index) => index)
+const publicKeyB64 = Buffer.from(publicKey).toString('base64')
+const hostFingerprint = Buffer.from(sha256(publicKey)).toString('base64url').slice(0, 16)
+
+const hosts = [{ id: 'host-1', publicKeyB64 }] as unknown as HostCatalogEntry[]
+
+// APNs nests Orca's fields beside `aps`; FCM sends them flat and stringified.
+function apnsData(orca: Record): unknown {
+ return { aps: { alert: { title: 'Orca', body: 'Agent needs input' } }, orca }
+}
+
+function fcmData(orca: Record): unknown {
+ return Object.fromEntries(Object.entries(orca).map(([key, value]) => [key, String(value)]))
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ storage.clear()
+ storage.set('orca:pushNotificationsEnabled', 'true')
+ storage.set('orca:remotePushEnabled', 'true')
+ resetHostNotificationSessionsForTests()
+ vi.mocked(loadHostCatalog).mockResolvedValue(hosts)
+})
+
+describe('shouldSuppressForegroundPush', () => {
+ it('suppresses a push whose id and seq the socket already delivered', async () => {
+ const session = getHostNotificationSession('host-1')
+ session.lastDeliveredEpoch = 'epoch-1'
+ session.seen.add('id:agent:one#7')
+
+ await expect(
+ shouldSuppressForegroundPush(
+ apnsData({
+ hostFingerprint,
+ notificationId: 'agent:one',
+ notificationSeq: 7,
+ notificationEpoch: 'epoch-1'
+ })
+ )
+ ).resolves.toBe(true)
+ })
+
+ it('shows an unseen push and marks it so the socket replay is dropped', async () => {
+ const data = apnsData({
+ hostFingerprint,
+ notificationId: 'agent:one',
+ notificationSeq: 7,
+ notificationEpoch: 'epoch-1'
+ })
+
+ await expect(shouldSuppressForegroundPush(data)).resolves.toBe(false)
+
+ expect(getHostNotificationSession('host-1').seen.has('id:agent:one#7')).toBe(true)
+ await expect(shouldSuppressForegroundPush(data)).resolves.toBe(true)
+ })
+
+ it('reads the flat stringified fields an FCM data message carries', async () => {
+ const session = getHostNotificationSession('host-1')
+ session.lastDeliveredEpoch = 'epoch-1'
+ session.seen.add('id:agent:one#7')
+
+ await expect(
+ shouldSuppressForegroundPush(
+ fcmData({
+ hostFingerprint,
+ notificationId: 'agent:one',
+ notificationSeq: 7,
+ notificationEpoch: 'epoch-1'
+ })
+ )
+ ).resolves.toBe(true)
+ })
+
+ it('keys a terminal bell on its seq alone, since it carries no notification id', async () => {
+ const session = getHostNotificationSession('host-1')
+ session.lastDeliveredEpoch = 'epoch-1'
+ session.seen.add('seq:4')
+
+ await expect(
+ shouldSuppressForegroundPush(
+ apnsData({
+ hostFingerprint,
+ source: 'terminal-bell',
+ notificationSeq: 4,
+ notificationEpoch: 'epoch-1'
+ })
+ )
+ ).resolves.toBe(true)
+ })
+
+ it('shows a push that names no counter lifetime without letting it claim a key', async () => {
+ const session = getHostNotificationSession('host-1')
+ session.lastDeliveredEpoch = 'epoch-1'
+ session.seen.add('seq:4')
+
+ // Without an epoch the seq cannot be tied to this counter, so a forged seq:4
+ // must neither be swallowed against it nor stop the real bell at seq 4.
+ await expect(
+ shouldSuppressForegroundPush(apnsData({ hostFingerprint, notificationSeq: 4 }))
+ ).resolves.toBe(false)
+ await expect(
+ shouldSuppressForegroundPush(apnsData({ hostFingerprint, notificationSeq: 5 }))
+ ).resolves.toBe(false)
+ expect(session.seen.has('seq:5')).toBe(false)
+ })
+
+ it('voids seen keys from a previous desktop lifetime before testing its own', async () => {
+ const session = getHostNotificationSession('host-1')
+ session.lastDeliveredEpoch = 'epoch-old'
+ session.seen.add('seq:4')
+
+ await expect(
+ shouldSuppressForegroundPush(
+ apnsData({ hostFingerprint, notificationSeq: 4, notificationEpoch: 'epoch-new' })
+ )
+ ).resolves.toBe(false)
+ })
+
+ it('leaves a locally scheduled notification to the existing path', async () => {
+ await expect(
+ shouldSuppressForegroundPush({ hostId: 'host-1', source: 'agent-task-complete' })
+ ).resolves.toBe(false)
+ expect(loadHostCatalog).not.toHaveBeenCalled()
+ })
+
+ it('suppresses a push for a host this phone no longer has, since its tap routes nowhere', async () => {
+ vi.mocked(loadHostCatalog).mockResolvedValue([])
+
+ await expect(
+ shouldSuppressForegroundPush(apnsData({ hostFingerprint, notificationSeq: 1 }))
+ ).resolves.toBe(true)
+ })
+
+ it('seeds the persisted watermark before adopting, so a push cannot void it', async () => {
+ storage.set(
+ 'orca:mobileNotificationsWatermark:host-1',
+ JSON.stringify({ seq: 42, epoch: 'epoch-1' })
+ )
+
+ await shouldSuppressForegroundPush(
+ apnsData({ hostFingerprint, notificationSeq: 43, notificationEpoch: 'epoch-1' })
+ )
+
+ // Unseeded, the null epoch reads as a new counter lifetime: the seq resets to 0
+ // and {seq: 0} is persisted over a watermark the next reconnect still needs.
+ expect(getHostNotificationSession('host-1').lastDeliveredSeq).toBe(42)
+ expect(AsyncStorage.setItem).not.toHaveBeenCalled()
+ })
+
+ it('shows a coalesced summary without claiming the key of the one event it names', async () => {
+ await expect(
+ shouldSuppressForegroundPush(
+ apnsData({
+ hostFingerprint,
+ notificationId: 'agent:one',
+ notificationSeq: 7,
+ coalescedCount: 3
+ })
+ )
+ ).resolves.toBe(false)
+
+ // Claiming it would make the socket swallow the banner for agent:one itself,
+ // which the summary only ever counted.
+ expect(getHostNotificationSession('host-1').seen.has('id:agent:one#7')).toBe(false)
+ })
+})
+
+describe('pushNotificationRouteData', () => {
+ it('routes a tap by mapping the fingerprint to the paired host id', () => {
+ const data = pushNotificationRouteData(
+ apnsData({
+ hostFingerprint,
+ notificationId: 'agent:one',
+ worktreeId: 'repo::/Users/me/orca/workspaces/feature',
+ source: 'agent-task-complete'
+ }),
+ hosts
+ )
+
+ expect(getNotificationNavigationTarget(data, { knownHostIds: new Set(['host-1']) })).toEqual({
+ hostId: 'host-1',
+ sessionTarget: {
+ name: '[hostId]/session/[worktreeId]',
+ params: { hostId: 'host-1', worktreeId: 'repo::/Users/me/orca/workspaces/feature' }
+ }
+ })
+ })
+
+ it('falls back to the host screen for a push with no worktree', () => {
+ const data = pushNotificationRouteData(
+ fcmData({ hostFingerprint, source: 'terminal-bell' }),
+ hosts
+ )
+
+ expect(getNotificationNavigationTarget(data)).toEqual({
+ hostId: 'host-1',
+ sessionTarget: null
+ })
+ })
+
+ it('passes locally scheduled data through untouched', () => {
+ const data = { hostId: 'host-9', source: 'agent-task-complete' }
+
+ expect(pushNotificationRouteData(data, hosts)).toBe(data)
+ })
+
+ it('leaves an unresolvable fingerprint unrouted rather than guessing a host', () => {
+ const data = pushNotificationRouteData(apnsData({ hostFingerprint: '0123456789abcdef' }), hosts)
+
+ expect(getNotificationNavigationTarget(data)).toBeNull()
+ })
+
+ it('leaves a remote push unrouted when no host catalog could be read', () => {
+ const data = { hostId: 'host-1', orca: { hostFingerprint, notificationId: 'agent:one' } }
+
+ expect(pushNotificationRouteData(data, [], true)).toBeNull()
+ })
+
+ it('leaves a remote push with no fingerprint unrouted instead of treating it as local', () => {
+ const data = { hostId: 'host-1', worktreeId: 'wt-1', source: 'agent-task-complete' }
+
+ expect(pushNotificationRouteData(data, hosts, true)).toBeNull()
+ // The same shape from this app's own scheduler still routes.
+ expect(pushNotificationRouteData(data, hosts, false)).toBe(data)
+ })
+
+ it('recognises only a provider-delivered trigger as remote', () => {
+ expect(isRemotePushTrigger({ type: 'push' })).toBe(true)
+ expect(isRemotePushTrigger({ type: 'timeInterval', seconds: 1 })).toBe(false)
+ expect(isRemotePushTrigger({ channelId: 'orca-desktop' })).toBe(false)
+ expect(isRemotePushTrigger(null)).toBe(false)
+ expect(isRemotePushTrigger(undefined)).toBe(false)
+ })
+
+ it('drops a gateway payload that pairs an unresolvable fingerprint with a stray hostId', () => {
+ const data = {
+ hostId: 'host-1',
+ orca: { hostFingerprint: '0123456789abcdef', notificationId: 'agent:one' }
+ }
+
+ // Returning the raw data would let the stray hostId route a tap the push never named.
+ expect(pushNotificationRouteData(data, hosts)).toBeNull()
+ expect(
+ getNotificationNavigationTarget(pushNotificationRouteData(data, hosts), {
+ knownHostIds: new Set(['host-1'])
+ })
+ ).toBeNull()
+ })
+})
diff --git a/mobile/src/notifications/push-receive.ts b/mobile/src/notifications/push-receive.ts
new file mode 100644
index 00000000000..c920b6bda29
--- /dev/null
+++ b/mobile/src/notifications/push-receive.ts
@@ -0,0 +1,121 @@
+import { allowsLocalNotification } from './notification-viewing-policy'
+import { loadPushNotificationsEnabled, loadRemotePushEnabled } from '../storage/preferences'
+import { loadHostCatalog } from '../transport/host-store'
+import {
+ adoptNotificationEpoch,
+ getHostNotificationSession,
+ seedWatermarkFromStorage,
+ seenKeyForEvent
+} from './notification-reconnect-catchup'
+import { resolveHostIdForFingerprint } from './push-host-fingerprint'
+import { readOrcaPushPayload, type OrcaPushPayload } from './push-payload'
+
+async function resolvePushHostId(payload: OrcaPushPayload): Promise {
+ const hosts = await loadHostCatalog().catch(() => [])
+ return resolveHostIdForFingerprint(payload.hostFingerprint, hosts)
+}
+
+/**
+ * Whether a foreground notification is a push for an event the socket already
+ * delivered, and must therefore be swallowed instead of banner'd a second time.
+ *
+ * Marking happens here rather than in a received listener because the handler is
+ * the only hook that can actually suppress, and the key must be claimed exactly
+ * once — a listener running afterwards would mark an event the handler dropped.
+ */
+export async function shouldSuppressForegroundPush(data: unknown): Promise {
+ const payload = readOrcaPushPayload(data)
+ if (!payload) {
+ return false
+ }
+ const hostId = await resolvePushHostId(payload)
+ // Why suppressed rather than shown: the only pushes that outlive their host are
+ // ones a gateway registration still holds after a removal whose unregister never
+ // reached the desktop. A banner naming a host this phone no longer has cannot be
+ // tapped anywhere, so it is noise the user cannot act on or turn off per-host.
+ if (!hostId) {
+ return true
+ }
+ if (!(await loadPushNotificationsEnabled()) || !(await loadRemotePushEnabled())) {
+ return true
+ }
+ if (
+ !(await allowsLocalNotification(
+ { ...payload, source: payload.source ?? 'agent-task-complete' },
+ hostId
+ ))
+ ) {
+ return true
+ }
+ const session = getHostNotificationSession(hostId)
+ // Why seeded first: the socket may never have connected this launch (phone on
+ // cellular), leaving lastDeliveredEpoch null. Adopting against an unseeded session
+ // resets the seq to 0 and persists that over a valid watermark, so the next
+ // reconnect replays the desktop's whole retained buffer.
+ seedWatermarkFromStorage(session, hostId)
+ await session.watermarkSeeded
+ // A push that names no counter lifetime cannot claim a seq-derived key: the
+ // desktop always sends the epoch, so this is shown as-is and never marked.
+ if (payload.notificationEpoch == null) {
+ return false
+ }
+ // The seen keys are seq-derived, so a push from a new desktop lifetime must void
+ // them before its own key is tested against a counter that no longer exists.
+ adoptNotificationEpoch(session, hostId, payload.notificationEpoch)
+ // Why a coalesced summary is neither suppressed nor marked: it carries only the
+ // latest event's fields, so claiming that key would make the socket swallow the
+ // specific banner for an event the summary only ever counted.
+ if ((payload.coalescedCount ?? 0) > 1) {
+ return false
+ }
+ const key = seenKeyForEvent(payload)
+ if (!key) {
+ return false
+ }
+ if (session.seen.has(key)) {
+ return true
+ }
+ session.seen.add(key)
+ return false
+}
+
+/** Whether the OS says a notification came from a provider rather than this app. */
+export function isRemotePushTrigger(trigger: unknown): boolean {
+ return (
+ typeof trigger === 'object' &&
+ trigger !== null &&
+ (trigger as { readonly type?: unknown }).type === 'push'
+ )
+}
+
+/**
+ * Notification data a tap can route with: the gateway names the host by fingerprint,
+ * so it is mapped back to this device's hostId. Locally scheduled data passes
+ * through untouched, which is what keeps its taps on their existing path.
+ *
+ * Why null and not the raw data when the fingerprint does not resolve: a gateway
+ * payload is attacker-adjacent input, and passing it on would let a stray `hostId`
+ * beside the `orca` block route a tap at a host the push never named. A remote
+ * push with no fingerprint at all is the same input minus the block, so it is
+ * unrouted too rather than handed to the local path as if this app scheduled it.
+ */
+export function pushNotificationRouteData(
+ data: unknown,
+ hosts: readonly { readonly id: string; readonly publicKeyB64: string }[],
+ remote = false
+): unknown {
+ const payload = readOrcaPushPayload(data)
+ if (!payload) {
+ return remote ? null : data
+ }
+ const hostId = resolveHostIdForFingerprint(payload.hostFingerprint, hosts)
+ if (!hostId) {
+ return null
+ }
+ return {
+ hostId,
+ ...(payload.source ? { source: payload.source } : {}),
+ ...(payload.worktreeId ? { worktreeId: payload.worktreeId } : {}),
+ ...(payload.notificationId ? { notificationId: payload.notificationId } : {})
+ }
+}
diff --git a/mobile/src/notifications/push-registration.test.ts b/mobile/src/notifications/push-registration.test.ts
new file mode 100644
index 00000000000..22070bfdd79
--- /dev/null
+++ b/mobile/src/notifications/push-registration.test.ts
@@ -0,0 +1,412 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { RpcClient, SendRequestOptions } from '../transport/rpc-client'
+import type { RpcResponse } from '../transport/types'
+import {
+ loadRemotePushAgentStates,
+ loadRemotePushEnabled,
+ loadRemotePushFilter,
+ loadRemotePushHostRegistrations,
+ saveRemotePushAgentStates,
+ saveRemotePushEnabled,
+ saveRemotePushHostRegistrations,
+ type RemotePushAgentState,
+ type RemotePushHostRegistrations
+} from '../storage/preferences'
+import { addPushTokenListener, getDevicePushToken, type MobilePushToken } from './push-token'
+import {
+ NOTIFICATIONS_REMOTE_PUSH_CAPABILITY,
+ attachPushRegistration,
+ resetPushRegistrationForTests,
+ setRemotePushAgentStates,
+ setRemotePushEnabled,
+ startPushTokenSync,
+ unregisterPushForRemovedHost
+} from './push-registration'
+
+vi.mock('../storage/preferences', () => ({
+ loadRemotePushEnabled: vi.fn(),
+ saveRemotePushEnabled: vi.fn(),
+ loadRemotePushAgentStates: vi.fn(),
+ saveRemotePushAgentStates: vi.fn(),
+ loadRemotePushFilter: vi.fn(),
+ loadRemotePushHostRegistrations: vi.fn(),
+ saveRemotePushHostRegistrations: vi.fn()
+}))
+
+vi.mock('./push-token', () => ({
+ getDevicePushToken: vi.fn(),
+ addPushTokenListener: vi.fn()
+}))
+
+const IOS_TOKEN: MobilePushToken = {
+ platform: 'ios',
+ token: 'a'.repeat(64),
+ apnsEnvironment: 'production'
+}
+
+// Every await in the module resolves immediately, so one macrotask drains the whole
+// per-host reconcile chain no matter how many hops deep it happens to be.
+function flush(): Promise {
+ return new Promise((resolve) => setTimeout(resolve, 0))
+}
+
+function ok(result: unknown): RpcResponse {
+ return { id: 'req', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
+}
+
+type SentRequest = { method: string; params?: unknown; options?: SendRequestOptions }
+
+function makeClient(capabilities: readonly string[]): {
+ client: Pick
+ sent: SentRequest[]
+} {
+ const sent: SentRequest[] = []
+ const client = {
+ sendRequest: vi.fn(async (method: string, params?: unknown, options?: SendRequestOptions) => {
+ sent.push({ method, params, options })
+ if (method === 'status.get') {
+ return ok({ capabilities: [...capabilities] })
+ }
+ if (method === 'notifications.registerPush') {
+ return ok({ registered: true, registrationId: 'registration-1' })
+ }
+ if (method === 'notifications.unregisterPush') {
+ return ok({ unregistered: true })
+ }
+ return ok(null)
+ })
+ }
+ return { client, sent }
+}
+
+function methodsIn(sent: SentRequest[]): string[] {
+ return sent.map((request) => request.method)
+}
+
+let enabled = false
+let agentStates: readonly RemotePushAgentState[] = ['needs-input', 'finished']
+let stored: RemotePushHostRegistrations
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ resetPushRegistrationForTests()
+ enabled = false
+ agentStates = ['needs-input', 'finished']
+ stored = { registeredHostIds: [], pendingUnregisterHostIds: [] }
+
+ vi.mocked(loadRemotePushEnabled).mockImplementation(async () => enabled)
+ vi.mocked(saveRemotePushEnabled).mockImplementation(async (value) => {
+ enabled = value
+ })
+ vi.mocked(loadRemotePushAgentStates).mockImplementation(async () => agentStates)
+ vi.mocked(saveRemotePushAgentStates).mockImplementation(async (value) => {
+ agentStates = value
+ })
+ vi.mocked(loadRemotePushFilter).mockImplementation(async () => ({
+ sources: ['agent-task-complete', 'terminal-bell', 'plugin'],
+ agentStates
+ }))
+ vi.mocked(loadRemotePushHostRegistrations).mockImplementation(async () => stored)
+ vi.mocked(saveRemotePushHostRegistrations).mockImplementation(async (value) => {
+ stored = value
+ })
+ vi.mocked(getDevicePushToken).mockResolvedValue(IOS_TOKEN)
+})
+
+describe('push registration capability gating', () => {
+ it('registers a connected host that advertises remote push', async () => {
+ const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY])
+ await setRemotePushEnabled(true)
+
+ attachPushRegistration('host-1', client)
+ await flush()
+
+ const register = sent.find((request) => request.method === 'notifications.registerPush')
+ expect(register?.params).toEqual({
+ platform: 'ios',
+ token: IOS_TOKEN.token,
+ apnsEnvironment: 'production',
+ filter: {
+ sources: ['agent-task-complete', 'terminal-bell', 'plugin'],
+ agentStates: ['needs-input', 'finished']
+ }
+ })
+ expect(stored.registeredHostIds).toEqual(['host-1'])
+ })
+
+ it('never calls registerPush on a host without the capability', async () => {
+ const { client, sent } = makeClient(['some-other.v1'])
+ await setRemotePushEnabled(true)
+
+ attachPushRegistration('host-legacy', client)
+ await flush()
+
+ expect(methodsIn(sent)).toEqual(['status.get'])
+ expect(stored.registeredHostIds).toEqual([])
+ })
+
+ it('leaves a capable host alone while the switch is off', async () => {
+ const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY])
+
+ attachPushRegistration('host-1', client)
+ await flush()
+
+ expect(methodsIn(sent)).toEqual(['status.get'])
+ })
+
+ it('omits apnsEnvironment for an Android token', async () => {
+ vi.mocked(getDevicePushToken).mockResolvedValue({ platform: 'android', token: 'fcm-token' })
+ const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY])
+ await setRemotePushEnabled(true)
+
+ attachPushRegistration('host-1', client)
+ await flush()
+
+ const register = sent.find((request) => request.method === 'notifications.registerPush')
+ expect(register?.params).toMatchObject({ platform: 'android', token: 'fcm-token' })
+ expect(register?.params).not.toHaveProperty('apnsEnvironment')
+ })
+
+ it('registers nothing when the device has no push token at all', async () => {
+ vi.mocked(getDevicePushToken).mockResolvedValue(null)
+ const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY])
+ await setRemotePushEnabled(true)
+
+ attachPushRegistration('host-simulator', client)
+ await flush()
+
+ expect(methodsIn(sent)).toEqual(['status.get'])
+ })
+
+ it('asks only once when the host answers that it has no push capability', async () => {
+ const { client, sent } = makeClient(['some-other.v1'])
+ await setRemotePushEnabled(true)
+ attachPushRegistration('host-legacy', client)
+ await flush()
+
+ await setRemotePushAgentStates(['needs-input'])
+ await flush()
+
+ expect(methodsIn(sent)).toEqual(['status.get'])
+ })
+
+ it('re-probes a host whose first status.get never answered', async () => {
+ const sent: string[] = []
+ let probeFails = true
+ const client = {
+ sendRequest: vi.fn(async (method: string) => {
+ sent.push(method)
+ if (method === 'status.get') {
+ if (probeFails) {
+ throw new Error('request timed out')
+ }
+ return ok({ capabilities: [NOTIFICATIONS_REMOTE_PUSH_CAPABILITY] })
+ }
+ return ok({ registered: true, registrationId: 'registration-1' })
+ })
+ }
+ await setRemotePushEnabled(true)
+ attachPushRegistration('host-1', client)
+ await flush()
+ expect(sent).toEqual(['status.get'])
+
+ // A latched `false` would keep this host unregistered for the connection's life.
+ probeFails = false
+ await setRemotePushAgentStates(['needs-input'])
+ await flush()
+
+ expect(sent).toEqual(['status.get', 'status.get', 'notifications.registerPush'])
+ })
+
+ it('retries the device token on the next reconcile after the device had none', async () => {
+ vi.mocked(getDevicePushToken).mockResolvedValueOnce(null).mockResolvedValue(IOS_TOKEN)
+ const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY])
+ await setRemotePushEnabled(true)
+ attachPushRegistration('host-1', client)
+ await flush()
+ expect(methodsIn(sent)).toEqual(['status.get'])
+
+ // A token can be missing only for now — APNs registration still in flight.
+ await setRemotePushAgentStates(['needs-input'])
+ await flush()
+
+ expect(methodsIn(sent)).toContain('notifications.registerPush')
+ })
+})
+
+describe('push registration token and filter changes', () => {
+ it('re-registers every connected host when the provider rolls the token', async () => {
+ let onTokenChange: ((token: MobilePushToken) => void) | null = null
+ vi.mocked(addPushTokenListener).mockImplementation((listener) => {
+ onTokenChange = listener
+ return () => {}
+ })
+ const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY])
+ await setRemotePushEnabled(true)
+ attachPushRegistration('host-1', client)
+ await flush()
+ startPushTokenSync()
+
+ onTokenChange?.({ platform: 'ios', token: 'b'.repeat(64), apnsEnvironment: 'sandbox' })
+ await flush()
+
+ const registers = sent.filter((request) => request.method === 'notifications.registerPush')
+ expect(registers).toHaveLength(2)
+ expect(registers[1]?.params).toMatchObject({
+ token: 'b'.repeat(64),
+ apnsEnvironment: 'sandbox'
+ })
+ })
+
+ it('re-registers with the narrowed filter when a sub-switch is turned off', async () => {
+ const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY])
+ await setRemotePushEnabled(true)
+ attachPushRegistration('host-1', client)
+ await flush()
+
+ await setRemotePushAgentStates(['needs-input'])
+ await flush()
+
+ const registers = sent.filter((request) => request.method === 'notifications.registerPush')
+ expect(registers).toHaveLength(2)
+ expect(registers[1]?.params).toMatchObject({
+ filter: {
+ sources: ['agent-task-complete', 'terminal-bell', 'plugin'],
+ agentStates: ['needs-input']
+ }
+ })
+ })
+})
+
+describe('push unregistration', () => {
+ it('unregisters a connected host as soon as the switch goes off', async () => {
+ const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY])
+ await setRemotePushEnabled(true)
+ attachPushRegistration('host-1', client)
+ await flush()
+
+ await setRemotePushEnabled(false)
+ await flush()
+
+ expect(methodsIn(sent)).toContain('notifications.unregisterPush')
+ expect(stored.registeredHostIds).toEqual([])
+ expect(stored.pendingUnregisterHostIds).toEqual([])
+ })
+
+ it('retries the unregister on a host that was offline when the switch went off', async () => {
+ const first = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY])
+ await setRemotePushEnabled(true)
+ const detach = attachPushRegistration('host-1', first.client)
+ await flush()
+ detach()
+
+ await setRemotePushEnabled(false)
+ await flush()
+ expect(methodsIn(first.sent)).not.toContain('notifications.unregisterPush')
+ expect(stored.pendingUnregisterHostIds).toEqual(['host-1'])
+
+ // A fresh process: only the persisted intent survives the restart.
+ resetPushRegistrationForTests()
+ const reconnected = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY])
+ attachPushRegistration('host-1', reconnected.client)
+ await flush()
+
+ // No probe first: a pending entry is a switch-off the user already performed, so
+ // it must not wait on a status.get that may never answer.
+ expect(methodsIn(reconnected.sent)).toEqual(['notifications.unregisterPush'])
+ expect(stored.pendingUnregisterHostIds).toEqual([])
+ })
+
+ it('keeps the pending intent when the retry itself fails', async () => {
+ stored = { registeredHostIds: ['host-1'], pendingUnregisterHostIds: ['host-1'] }
+ const client = {
+ sendRequest: vi.fn(async (method: string) =>
+ method === 'status.get'
+ ? ok({ capabilities: [NOTIFICATIONS_REMOTE_PUSH_CAPABILITY] })
+ : Promise.reject(new Error('socket closed'))
+ )
+ }
+
+ attachPushRegistration('host-1', client)
+ await flush()
+
+ expect(stored.pendingUnregisterHostIds).toEqual(['host-1'])
+ })
+
+ it('unregisters best-effort before a removed host loses its credentials', async () => {
+ const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY])
+ await setRemotePushEnabled(true)
+ attachPushRegistration('host-1', client)
+ await flush()
+
+ await unregisterPushForRemovedHost('host-1')
+
+ expect(methodsIn(sent)).toContain('notifications.unregisterPush')
+ expect(stored.registeredHostIds).toEqual([])
+ expect(stored.pendingUnregisterHostIds).toEqual([])
+ })
+
+ it('drops a removed host that was never connected without any request', async () => {
+ stored = { registeredHostIds: ['host-gone'], pendingUnregisterHostIds: ['host-gone'] }
+
+ await unregisterPushForRemovedHost('host-gone')
+
+ expect(stored).toEqual({ registeredHostIds: [], pendingUnregisterHostIds: [] })
+ })
+
+ it('unregisters a pending host even when its capability probe never answers', async () => {
+ stored = { registeredHostIds: ['host-1'], pendingUnregisterHostIds: ['host-1'] }
+ const sent: string[] = []
+ const client = {
+ sendRequest: vi.fn(async (method: string) => {
+ sent.push(method)
+ if (method === 'status.get') {
+ throw new Error('request timed out')
+ }
+ return ok({ unregistered: true })
+ })
+ }
+
+ attachPushRegistration('host-1', client)
+ await flush()
+
+ // Gating this on the probe leaves the gateway pushing while the switch reads off.
+ expect(sent).toEqual(['notifications.unregisterPush'])
+ expect(stored.pendingUnregisterHostIds).toEqual([])
+ })
+
+ it('re-arms the unregister when the switch goes off while a register is in flight', async () => {
+ const sent: string[] = []
+ let releaseRegister: (() => void) | null = null
+ const client = {
+ sendRequest: vi.fn(async (method: string) => {
+ sent.push(method)
+ if (method === 'status.get') {
+ return ok({ capabilities: [NOTIFICATIONS_REMOTE_PUSH_CAPABILITY] })
+ }
+ if (method === 'notifications.registerPush') {
+ await new Promise((resolve) => {
+ releaseRegister = resolve
+ })
+ return ok({ registered: true, registrationId: 'registration-1' })
+ }
+ return ok({ unregistered: true })
+ })
+ }
+ await setRemotePushEnabled(true)
+ attachPushRegistration('host-1', client)
+ await flush()
+
+ // The sweep snapshots `registered` while this host is still only in flight.
+ const switchedOff = setRemotePushEnabled(false)
+ await flush()
+ releaseRegister?.()
+ await switchedOff
+ await flush()
+
+ // Recording the late success would leave a live gateway registration behind a
+ // switch that reads off, with nothing pending to ever retract it.
+ expect(sent).toContain('notifications.unregisterPush')
+ expect(stored).toEqual({ registeredHostIds: [], pendingUnregisterHostIds: [] })
+ })
+})
diff --git a/mobile/src/notifications/push-registration.ts b/mobile/src/notifications/push-registration.ts
new file mode 100644
index 00000000000..c98e50d41e0
--- /dev/null
+++ b/mobile/src/notifications/push-registration.ts
@@ -0,0 +1,289 @@
+import {
+ saveNotificationDeliveryPreferences,
+ type NotificationDeliveryPreferences
+} from './notification-delivery-preferences'
+import type {
+ MobilePushRegisterInput,
+ MobilePushRegisterResult
+} from '../../../src/shared/mobile-push-contract'
+import { NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version'
+import type { RpcClient } from '../transport/rpc-client'
+import {
+ loadRemotePushEnabled,
+ loadRemotePushFilter,
+ loadRemotePushHostRegistrations,
+ saveRemotePushAgentStates,
+ saveRemotePushEnabled,
+ saveRemotePushHostRegistrations,
+ type RemotePushAgentState,
+ type RemotePushFilter
+} from '../storage/preferences'
+import { addPushTokenListener, getDevicePushToken, type MobilePushToken } from './push-token'
+
+export const NOTIFICATIONS_REMOTE_PUSH_CAPABILITY = NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY
+
+type PushClient = Pick
+
+const REQUEST_TIMEOUT_MS = 5_000
+const REMOVAL_TIMEOUT_MS = 2_000
+
+type HostPushState = {
+ client: PushClient | null
+ // An unanswered probe is unknown, not unsupported.
+ supported: boolean | null
+ chain: Promise
+}
+
+type RegistrationRecords = { registered: Set; pending: Set }
+
+const hostsById = new Map()
+let registrationRecords: RegistrationRecords | null = null
+let tokenPromise: Promise | null = null
+// A late registration must not overwrite a newer preference or consent choice.
+let consentGeneration = 0
+
+function hostState(hostId: string): HostPushState {
+ let state = hostsById.get(hostId)
+ if (!state) {
+ state = { client: null, supported: null, chain: Promise.resolve() }
+ hostsById.set(hostId, state)
+ }
+ return state
+}
+
+async function readRecords(): Promise {
+ if (!registrationRecords) {
+ const stored = await loadRemotePushHostRegistrations()
+ registrationRecords ??= {
+ registered: new Set(stored.registeredHostIds),
+ pending: new Set(stored.pendingUnregisterHostIds)
+ }
+ }
+ return registrationRecords
+}
+
+async function mutateRecords(mutate: (value: RegistrationRecords) => void): Promise {
+ const value = await readRecords()
+ mutate(value)
+ await saveRemotePushHostRegistrations({
+ registeredHostIds: [...value.registered],
+ pendingUnregisterHostIds: [...value.pending]
+ }).catch(() => {})
+}
+
+// A missing token is retried: APNs registration may still be in flight.
+async function currentToken(): Promise {
+ if (!tokenPromise) {
+ const pending: Promise = getDevicePushToken().then((token) => {
+ if (!token && tokenPromise === pending) {
+ tokenPromise = null
+ }
+ return token
+ })
+ tokenPromise = pending
+ }
+ return tokenPromise
+}
+
+async function readRemotePushCapability(client: PushClient): Promise {
+ try {
+ const response = await client.sendRequest('status.get')
+ if (!response.ok) {
+ return null
+ }
+ const result = response.result
+ if (!result || typeof result !== 'object') {
+ return false
+ }
+ const capabilities = (result as { capabilities?: unknown }).capabilities
+ return (
+ Array.isArray(capabilities) && capabilities.includes(NOTIFICATIONS_REMOTE_PUSH_CAPABILITY)
+ )
+ } catch {
+ return null
+ }
+}
+
+async function sendRegister(
+ client: PushClient,
+ token: MobilePushToken,
+ filter: RemotePushFilter
+): Promise {
+ const params: Omit = {
+ platform: token.platform,
+ token: token.token,
+ ...(token.apnsEnvironment ? { apnsEnvironment: token.apnsEnvironment } : {}),
+ filter: { ...filter, sources: [...filter.sources], agentStates: [...filter.agentStates] }
+ }
+ const response = await client
+ .sendRequest('notifications.registerPush', params, {
+ timeoutMs: REQUEST_TIMEOUT_MS,
+ failWhenDisconnected: true
+ })
+ .catch(() => null)
+ if (!response?.ok) {
+ return false
+ }
+ return (response.result as MobilePushRegisterResult | null)?.registered === true
+}
+
+async function sendUnregister(client: PushClient, timeoutMs: number): Promise {
+ const response = await client
+ .sendRequest('notifications.unregisterPush', null, {
+ timeoutMs,
+ failWhenDisconnected: true
+ })
+ .catch(() => null)
+ return response?.ok === true
+}
+
+async function reconcileHost(hostId: string): Promise {
+ const state = hostsById.get(hostId)
+ const client = state?.client
+ if (!state || !client) {
+ return
+ }
+ const generation = consentGeneration
+ const value = await readRecords()
+ // Unregister intent takes priority even before the capability probe answers.
+ if (value.pending.has(hostId)) {
+ if (state.supported === false || !(await sendUnregister(client, REQUEST_TIMEOUT_MS))) {
+ return
+ }
+ await mutateRecords((current) => {
+ current.pending.delete(hostId)
+ current.registered.delete(hostId)
+ })
+ // A preference change can invalidate a register without disabling push.
+ if (!(await loadRemotePushEnabled())) {
+ return
+ }
+ }
+ if (state.supported == null) {
+ const probed = await readRemotePushCapability(client)
+ if (state.client !== client) {
+ return
+ }
+ if (probed == null) {
+ return
+ }
+ state.supported = probed
+ }
+ if (!state.supported || state.client !== client) {
+ return
+ }
+ if (!(await loadRemotePushEnabled())) {
+ return
+ }
+ const token = await currentToken()
+ if (!token) {
+ return
+ }
+ if (!(await sendRegister(client, token, await loadRemotePushFilter()))) {
+ return
+ }
+ if (generation !== consentGeneration) {
+ await mutateRecords((current) => current.pending.add(hostId))
+ void enqueueReconcile(hostId)
+ return
+ }
+ await mutateRecords((current) => current.registered.add(hostId))
+}
+
+function enqueueReconcile(hostId: string): Promise {
+ const state = hostState(hostId)
+ const run = state.chain.then(() => reconcileHost(hostId)).catch(() => {})
+ state.chain = run
+ return run
+}
+
+async function reconcileAllHosts(): Promise {
+ await Promise.all([...hostsById.keys()].map((hostId) => enqueueReconcile(hostId)))
+}
+
+/**
+ * Track a host whose client has reached `connected`, registering (or retrying a
+ * pending unregister) as the current preference requires. The returned function
+ * detaches the client on disconnect; the host's tracked state survives it.
+ */
+export function attachPushRegistration(hostId: string, client: PushClient): () => void {
+ const state = hostState(hostId)
+ if (state.client !== client) {
+ state.client = client
+ state.supported = null
+ }
+ void enqueueReconcile(hostId)
+ return () => {
+ if (state.client === client) {
+ state.client = null
+ }
+ }
+}
+
+export async function setRemotePushEnabled(enabled: boolean): Promise {
+ consentGeneration++
+ await saveRemotePushEnabled(enabled)
+ await mutateRecords((current) => {
+ if (!enabled) {
+ for (const hostId of current.registered) {
+ current.pending.add(hostId)
+ }
+ return
+ }
+ current.pending.clear()
+ })
+ await reconcileAllHosts()
+}
+
+export async function setNotificationDeliveryPreferences(
+ value: NotificationDeliveryPreferences
+): Promise {
+ consentGeneration++
+ await saveNotificationDeliveryPreferences(value)
+ await reconcileAllHosts()
+}
+
+/** Re-registers every connected host so the gateway stores the narrowed filter. */
+export async function setRemotePushAgentStates(
+ states: readonly RemotePushAgentState[]
+): Promise {
+ consentGeneration++
+ await saveRemotePushAgentStates(states)
+ await reconcileAllHosts()
+}
+
+/**
+ * Best-effort unregister before the host's credentials are deleted.
+ *
+ * Why best-effort is all there is: the credentials are the only way back to that
+ * host, so a desktop that was offline here keeps its gateway registration and keeps
+ * pushing to this phone. shouldSuppressForegroundPush drops those in the foreground;
+ * background alerts stop only when that desktop unpairs the phone, or the switch is
+ * turned off here. Documented in docs/site/content/docs/notifications.mdx.
+ */
+export async function unregisterPushForRemovedHost(hostId: string): Promise {
+ const state = hostsById.get(hostId)
+ if (state?.client && state.supported !== false) {
+ await sendUnregister(state.client, REMOVAL_TIMEOUT_MS)
+ }
+ hostsById.delete(hostId)
+ await mutateRecords((current) => {
+ current.registered.delete(hostId)
+ current.pending.delete(hostId)
+ })
+}
+
+/** A rolled token stops delivering, so re-register every connected host at once. */
+export function startPushTokenSync(): () => void {
+ return addPushTokenListener((token) => {
+ tokenPromise = Promise.resolve(token)
+ void reconcileAllHosts()
+ })
+}
+
+export function resetPushRegistrationForTests(): void {
+ hostsById.clear()
+ registrationRecords = null
+ tokenPromise = null
+ consentGeneration = 0
+}
diff --git a/mobile/src/notifications/push-token.test.ts b/mobile/src/notifications/push-token.test.ts
new file mode 100644
index 00000000000..2a193430ac6
--- /dev/null
+++ b/mobile/src/notifications/push-token.test.ts
@@ -0,0 +1,92 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import * as Notifications from 'expo-notifications'
+import { addPushTokenListener, getDevicePushToken } from './push-token'
+
+vi.mock('expo-notifications', () => ({
+ getDevicePushTokenAsync: vi.fn(),
+ addPushTokenListener: vi.fn()
+}))
+
+const dev = globalThis as { __DEV__?: boolean }
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+afterEach(() => {
+ delete dev.__DEV__
+})
+
+describe('getDevicePushToken', () => {
+ it.each([
+ [true, 'sandbox'],
+ [false, 'production']
+ ])('reports apnsEnvironment for a __DEV__=%s iOS build as %s', async (isDev, environment) => {
+ dev.__DEV__ = isDev
+ vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
+ type: 'ios',
+ data: 'a'.repeat(64)
+ } as never)
+
+ await expect(getDevicePushToken()).resolves.toEqual({
+ platform: 'ios',
+ token: 'a'.repeat(64),
+ apnsEnvironment: environment
+ })
+ })
+
+ it('omits apnsEnvironment for Android, where FCM has no environment split', async () => {
+ vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
+ type: 'android',
+ data: 'fcm-registration-token'
+ } as never)
+
+ await expect(getDevicePushToken()).resolves.toEqual({
+ platform: 'android',
+ token: 'fcm-registration-token'
+ })
+ })
+
+ it.each([
+ ['a web push subscription', { type: 'web', data: { endpoint: 'https://example.test' } }],
+ ['an empty token', { type: 'ios', data: '' }]
+ ])('returns null for %s', async (_label, raw) => {
+ vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue(raw as never)
+
+ await expect(getDevicePushToken()).resolves.toBeNull()
+ })
+
+ it('returns null when the shell cannot mint a token at all', async () => {
+ vi.mocked(Notifications.getDevicePushTokenAsync).mockRejectedValue(new Error('no entitlement'))
+
+ await expect(getDevicePushToken()).resolves.toBeNull()
+ })
+})
+
+describe('addPushTokenListener', () => {
+ it('forwards a rolled native token and removes the subscription on teardown', () => {
+ const remove = vi.fn()
+ let emit: ((raw: unknown) => void) | null = null
+ vi.mocked(Notifications.addPushTokenListener).mockImplementation((listener) => {
+ emit = listener as (raw: unknown) => void
+ return { remove } as never
+ })
+ const seen: unknown[] = []
+
+ const stop = addPushTokenListener((token) => seen.push(token))
+ emit?.({ type: 'android', data: 'rolled' })
+ emit?.({ type: 'web', data: {} })
+ stop()
+
+ expect(seen).toEqual([{ platform: 'android', token: 'rolled' }])
+ expect(remove).toHaveBeenCalledTimes(1)
+ })
+
+ it('degrades to a no-op on a shell that cannot subscribe to token changes', () => {
+ vi.mocked(Notifications.addPushTokenListener).mockImplementation(() => {
+ throw new Error('no push support')
+ })
+
+ expect(() => addPushTokenListener(() => {})()).not.toThrow()
+ })
+})
diff --git a/mobile/src/notifications/push-token.ts b/mobile/src/notifications/push-token.ts
new file mode 100644
index 00000000000..5f29c0ec1dd
--- /dev/null
+++ b/mobile/src/notifications/push-token.ts
@@ -0,0 +1,59 @@
+import * as Notifications from 'expo-notifications'
+import type {
+ MobilePushApnsEnvironment,
+ MobilePushPlatform
+} from '../../../src/shared/mobile-push-contract'
+
+// Why: the native APNs/FCM token, not an Expo push token — Orca's own gateway
+// talks to Apple and Google directly, so it needs the raw device token.
+
+export type MobilePushToken = {
+ readonly platform: MobilePushPlatform
+ readonly token: string
+ readonly apnsEnvironment?: MobilePushApnsEnvironment
+}
+
+// Dev-client builds are debug and get sandbox APNs; TestFlight and App Store are release.
+function apnsEnvironment(): MobilePushApnsEnvironment {
+ return typeof __DEV__ !== 'undefined' && __DEV__ ? 'sandbox' : 'production'
+}
+
+function toMobilePushToken(raw: { type: string; data: unknown }): MobilePushToken | null {
+ if (typeof raw.data !== 'string' || raw.data.length === 0) {
+ return null
+ }
+ if (raw.type === 'ios') {
+ return { platform: 'ios', token: raw.data, apnsEnvironment: apnsEnvironment() }
+ }
+ // Web tokens carry an object payload and no Orca gateway path; only native counts.
+ return raw.type === 'android' ? { platform: 'android', token: raw.data } : null
+}
+
+/**
+ * The device's native push token, or null when this build cannot have one —
+ * a simulator, a de-Googled Android device, or a shell without the entitlement.
+ */
+export async function getDevicePushToken(): Promise {
+ try {
+ return toMobilePushToken(await Notifications.getDevicePushTokenAsync())
+ } catch {
+ return null
+ }
+}
+
+/** Providers can roll a token while the app runs; the old one stops delivering. */
+export function addPushTokenListener(listener: (token: MobilePushToken) => void): () => void {
+ try {
+ const subscription = Notifications.addPushTokenListener((raw) => {
+ const token = toMobilePushToken(raw)
+ if (token) {
+ listener(token)
+ }
+ })
+ return () => subscription.remove()
+ } catch {
+ // A shell with no push capability cannot subscribe; the caller is a root-level
+ // effect, so throwing here would take the whole app down over an optional feature.
+ return () => {}
+ }
+}
diff --git a/mobile/src/notifications/push-tray-dismissal.test.ts b/mobile/src/notifications/push-tray-dismissal.test.ts
new file mode 100644
index 00000000000..64ccbf7ebd9
--- /dev/null
+++ b/mobile/src/notifications/push-tray-dismissal.test.ts
@@ -0,0 +1,57 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import * as Notifications from 'expo-notifications'
+import { dismissPresentedPushNotification } from './push-tray-dismissal'
+
+vi.mock('expo-notifications', () => ({
+ getPresentedNotificationsAsync: vi.fn(),
+ dismissNotificationAsync: vi.fn()
+}))
+
+function presented(identifier: string, data: unknown): unknown {
+ return { request: { identifier, content: { data } } }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
+})
+
+describe('dismissPresentedPushNotification', () => {
+ it('dismisses only the tray entries whose push payload carries the same notification id', async () => {
+ vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([
+ presented('tray-1', {
+ orca: { hostFingerprint: 'fp0123456789abcd', notificationId: 'agent:one' }
+ }),
+ presented('tray-2', {
+ orca: { hostFingerprint: 'fp0123456789abcd', notificationId: 'agent:two' }
+ }),
+ // Flat FCM shape for the same notification, presented on Android.
+ presented('tray-3', { hostFingerprint: 'fp0123456789abcd', notificationId: 'agent:one' })
+ ] as never)
+
+ await dismissPresentedPushNotification('agent:one')
+
+ expect(vi.mocked(Notifications.dismissNotificationAsync).mock.calls.map(([id]) => id)).toEqual([
+ 'tray-1',
+ 'tray-3'
+ ])
+ })
+
+ it('ignores locally scheduled notifications, which the local registry already owns', async () => {
+ vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([
+ presented('tray-1', { hostId: 'host-1', notificationId: 'agent:one' })
+ ] as never)
+
+ await dismissPresentedPushNotification('agent:one')
+
+ expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled()
+ })
+
+ it('stays silent on a native shell that cannot query the tray', async () => {
+ vi.mocked(Notifications.getPresentedNotificationsAsync).mockRejectedValue(
+ new Error('unavailable')
+ )
+
+ await expect(dismissPresentedPushNotification('agent:one')).resolves.toBeUndefined()
+ })
+})
diff --git a/mobile/src/notifications/push-tray-dismissal.ts b/mobile/src/notifications/push-tray-dismissal.ts
new file mode 100644
index 00000000000..850c6488e3c
--- /dev/null
+++ b/mobile/src/notifications/push-tray-dismissal.ts
@@ -0,0 +1,30 @@
+import { readNativeNotificationData } from './native-notification-data'
+import * as Notifications from 'expo-notifications'
+import { readOrcaPushPayload } from './push-payload'
+
+/**
+ * Retire a push the OS presented for a notification the desktop has now dismissed.
+ * The local scheduling registry knows nothing about it — the OS drew it while Orca
+ * was closed — so the notification tray is the only place it can be found.
+ *
+ * Kept out of push-receive.ts deliberately: this runs on the socket dismiss path,
+ * which must not pull the host store (and its native keychain deps) behind it.
+ */
+export async function dismissPresentedPushNotification(notificationId: string): Promise {
+ try {
+ const presented = await Notifications.getPresentedNotificationsAsync()
+ await Promise.all(
+ presented.map(async (notification) => {
+ const payload = readOrcaPushPayload(readNativeNotificationData(notification.request))
+ if (payload?.notificationId !== notificationId) {
+ return
+ }
+ await Notifications.dismissNotificationAsync(notification.request.identifier).catch(
+ () => {}
+ )
+ })
+ )
+ } catch {
+ // Older native shells lack the tray query; local dismissal still runs.
+ }
+}
diff --git a/mobile/src/notifications/push-tray-seen-seed.test.ts b/mobile/src/notifications/push-tray-seen-seed.test.ts
new file mode 100644
index 00000000000..377dc9dab18
--- /dev/null
+++ b/mobile/src/notifications/push-tray-seen-seed.test.ts
@@ -0,0 +1,124 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import * as Notifications from 'expo-notifications'
+import { sha256 } from '@noble/hashes/sha256'
+import { loadHostCatalog } from '../transport/host-store'
+import type { HostCatalogEntry } from '../transport/types'
+import {
+ getHostNotificationSession,
+ resetHostNotificationSessionsForTests
+} from './notification-reconnect-catchup'
+import { markPresentedPushesSeen, readPresentedPushSeenKeys } from './push-tray-seen-seed'
+
+vi.mock('expo-notifications', () => ({ getPresentedNotificationsAsync: vi.fn() }))
+
+vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn() }))
+
+vi.mock('@react-native-async-storage/async-storage', () => ({
+ default: {
+ getItem: vi.fn(async () => null),
+ setItem: vi.fn(async () => undefined)
+ }
+}))
+
+const publicKey = Uint8Array.from({ length: 32 }, (_, index) => index)
+const publicKeyB64 = Buffer.from(publicKey).toString('base64')
+const hostFingerprint = Buffer.from(sha256(publicKey)).toString('base64url').slice(0, 16)
+
+const hosts = [{ id: 'host-1', publicKeyB64 }] as unknown as HostCatalogEntry[]
+
+function presented(orca: Record): unknown {
+ const identifier = `tray-${String(orca.notificationId ?? 'bell')}`
+ return { request: { identifier, content: { data: { orca } } } }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ resetHostNotificationSessionsForTests()
+ vi.mocked(loadHostCatalog).mockResolvedValue(hosts)
+})
+
+describe('readPresentedPushSeenKeys', () => {
+ it('keys the tray entries the gateway pushed for this host', async () => {
+ vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([
+ presented({ hostFingerprint, notificationId: 'agent:one', notificationSeq: 6 }),
+ presented({ hostFingerprint, notificationSeq: 7 })
+ ] as never)
+
+ await expect(readPresentedPushSeenKeys('host-1')).resolves.toEqual([
+ { key: 'id:agent:one#6', epoch: undefined },
+ { key: 'seq:7', epoch: undefined }
+ ])
+ })
+
+ it('ignores a tray entry belonging to another paired host', async () => {
+ vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([
+ presented({ hostFingerprint: '0123456789abcdef', notificationId: 'agent:one' })
+ ] as never)
+
+ await expect(readPresentedPushSeenKeys('host-1')).resolves.toEqual([])
+ })
+
+ it('ignores a coalesced summary, whose key names a banner nobody has seen', async () => {
+ vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([
+ presented({
+ hostFingerprint,
+ notificationId: 'agent:one',
+ notificationSeq: 6,
+ coalescedCount: 3
+ })
+ ] as never)
+
+ await expect(readPresentedPushSeenKeys('host-1')).resolves.toEqual([])
+ })
+
+ it('ignores a locally scheduled notification, which the socket path already owns', async () => {
+ vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([
+ { request: { identifier: 'tray-1', content: { data: { hostId: 'host-1' } } } }
+ ] as never)
+
+ await expect(readPresentedPushSeenKeys('host-1')).resolves.toEqual([])
+ expect(loadHostCatalog).toHaveBeenCalled()
+ })
+
+ it('stays silent on a native shell that cannot query the tray', async () => {
+ vi.mocked(Notifications.getPresentedNotificationsAsync).mockRejectedValue(
+ new Error('unavailable')
+ )
+
+ await expect(readPresentedPushSeenKeys('host-1')).resolves.toEqual([])
+ })
+})
+
+describe('markPresentedPushesSeen', () => {
+ it('claims the keys without touching the watermark', () => {
+ const session = getHostNotificationSession('host-1')
+ session.lastDeliveredEpoch = 'epoch-1'
+
+ markPresentedPushesSeen(session, [{ key: 'id:agent:one#9', epoch: 'epoch-1' }])
+
+ expect(session.seen.has('id:agent:one#9')).toBe(true)
+ // A push seq proves one event was shown, not that everything below it was.
+ expect(session.lastDeliveredSeq).toBe(0)
+ })
+
+ it('drops a key that names no counter lifetime at all', () => {
+ const session = getHostNotificationSession('host-1')
+ session.lastDeliveredEpoch = 'epoch-1'
+
+ markPresentedPushesSeen(session, [{ key: 'seq:4', epoch: undefined }])
+
+ // The desktop always sends an epoch; a key without one cannot be shown to belong
+ // to this counter, and claiming it would drop the real bell at seq 4.
+ expect(session.seen.has('seq:4')).toBe(false)
+ })
+
+ it('drops a key from a desktop lifetime that has already been retired', () => {
+ const session = getHostNotificationSession('host-1')
+ session.lastDeliveredEpoch = 'epoch-2'
+
+ markPresentedPushesSeen(session, [{ key: 'seq:4', epoch: 'epoch-1' }])
+
+ // The new counter re-issues seq 4, so the stale key would drop a real bell.
+ expect(session.seen.has('seq:4')).toBe(false)
+ })
+})
diff --git a/mobile/src/notifications/push-tray-seen-seed.ts b/mobile/src/notifications/push-tray-seen-seed.ts
new file mode 100644
index 00000000000..a7b83dd7d38
--- /dev/null
+++ b/mobile/src/notifications/push-tray-seen-seed.ts
@@ -0,0 +1,72 @@
+import { readNativeNotificationData } from './native-notification-data'
+import * as Notifications from 'expo-notifications'
+import { loadHostCatalog } from '../transport/host-store'
+import { seenKeyForEvent, type HostNotificationSession } from './notification-reconnect-catchup'
+import { resolveHostIdForFingerprint } from './push-host-fingerprint'
+import { readOrcaPushPayload } from './push-payload'
+
+/**
+ * Dedup keys for the pushes the OS has already drawn for one host.
+ *
+ * Why this exists: a push shown while Orca was closed never ran through the
+ * foreground handler, so nothing in this process claimed its key. The reconnect
+ * catch-up then replays that same event and shows a second banner for it.
+ *
+ * Kept separate from push-tray-dismissal.ts, which must stay free of the host
+ * store (and its native keychain deps) because it runs on the socket dismiss path.
+ */
+export type PresentedPushSeenKey = { readonly key: string; readonly epoch: string | undefined }
+
+export async function readPresentedPushSeenKeys(
+ hostId: string
+): Promise {
+ try {
+ const presented = await Notifications.getPresentedNotificationsAsync()
+ if (presented.length === 0) {
+ return []
+ }
+ const hosts = await loadHostCatalog().catch(() => [])
+ const keys: PresentedPushSeenKey[] = []
+ for (const notification of presented) {
+ const payload = readOrcaPushPayload(readNativeNotificationData(notification.request))
+ // A coalesced summary stands in for N events while carrying only the latest
+ // one's fields, so its key belongs to a banner the user has NOT seen.
+ if (!payload || (payload.coalescedCount ?? 0) > 1) {
+ continue
+ }
+ if (resolveHostIdForFingerprint(payload.hostFingerprint, hosts) !== hostId) {
+ continue
+ }
+ const key = seenKeyForEvent(payload)
+ if (key) {
+ keys.push({ key, epoch: payload.notificationEpoch })
+ }
+ }
+ return keys
+ } catch {
+ // Older native shells lack the tray query; the catch-up replays as it did before.
+ return []
+ }
+}
+
+/**
+ * Claim the tray's keys on the session, skipping any that do not name the live
+ * counter lifetime. A push without an epoch cannot be tied to this counter, and
+ * the desktop always sends one, so it is left unclaimed rather than allowed to
+ * swallow a real event at the same seq.
+ *
+ * The watermark is deliberately untouched: a push seq proves one event was shown,
+ * not that everything below it was, and advancing past a gap would make the desktop
+ * cut the notifications in it forever.
+ */
+export function markPresentedPushesSeen(
+ session: HostNotificationSession,
+ keys: readonly PresentedPushSeenKey[]
+): void {
+ for (const { key, epoch } of keys) {
+ if (epoch == null || epoch !== session.lastDeliveredEpoch) {
+ continue
+ }
+ session.seen.add(key)
+ }
+}
diff --git a/mobile/src/notifications/socket-push-delivery-handoff.test.ts b/mobile/src/notifications/socket-push-delivery-handoff.test.ts
new file mode 100644
index 00000000000..43dbfa1df73
--- /dev/null
+++ b/mobile/src/notifications/socket-push-delivery-handoff.test.ts
@@ -0,0 +1,81 @@
+import { beforeEach, expect, it, vi } from 'vitest'
+import { AppState } from 'react-native'
+import { waitForSocketPushHandoff } from './socket-push-delivery-handoff'
+import { readPresentedPushSeenKeys } from './push-tray-seen-seed'
+import { loadRemotePushEnabled } from '../storage/preferences'
+import { seenKeyForEvent } from './notification-reconnect-catchup'
+
+let active: ((state: string) => void) | undefined
+const remove = vi.fn()
+vi.mock('react-native', () => ({
+ AppState: {
+ currentState: 'background',
+ addEventListener: vi.fn((_event, callback) => {
+ active = callback
+ return { remove }
+ })
+ }
+}))
+vi.mock('../storage/preferences', () => ({
+ loadRemotePushEnabled: vi.fn(async () => true),
+ loadRemotePushHostRegistrations: vi.fn(async () => ({ registeredHostIds: ['host'] }))
+}))
+vi.mock('./push-tray-seen-seed', () => ({ readPresentedPushSeenKeys: vi.fn(async () => []) }))
+const event = {
+ type: 'notification' as const,
+ source: 'agent-task-complete' as const,
+ title: 'Done',
+ body: '',
+ notificationId: 'done',
+ notificationSeq: 1,
+ notificationEpoch: 'epoch'
+}
+beforeEach(() => {
+ vi.clearAllMocks()
+ active = undefined
+ AppState.currentState = 'background'
+})
+
+it('waits for foreground and suppresses a live socket event already delivered by APNs', async () => {
+ vi.mocked(readPresentedPushSeenKeys).mockResolvedValue([
+ { key: seenKeyForEvent(event)!, epoch: 'epoch' }
+ ])
+ const delivery = waitForSocketPushHandoff(event, 'host', new AbortController().signal)
+ await vi.waitFor(() => expect(active).toBeDefined())
+ expect(readPresentedPushSeenKeys).not.toHaveBeenCalled()
+ AppState.currentState = 'active'
+ active?.('active')
+ expect(await delivery).toBe(false)
+ expect(remove).toHaveBeenCalledOnce()
+})
+
+it('falls back to local delivery on foreground when no provider notification arrived', async () => {
+ vi.mocked(readPresentedPushSeenKeys).mockResolvedValue([])
+ const delivery = waitForSocketPushHandoff(event, 'host', new AbortController().signal)
+ await vi.waitFor(() => expect(active).toBeDefined())
+ AppState.currentState = 'active'
+ active?.('active')
+ expect(await delivery).toBe(true)
+})
+
+it('releases the background wait when the subscription is disposed', async () => {
+ const controller = new AbortController()
+ const delivery = waitForSocketPushHandoff(event, 'host', controller.signal)
+ await vi.waitFor(() => expect(active).toBeDefined())
+ controller.abort()
+ expect(await delivery).toBe(false)
+ expect(remove).toHaveBeenCalledOnce()
+})
+
+it('keeps local background delivery when remote push is disabled', async () => {
+ vi.mocked(loadRemotePushEnabled).mockResolvedValueOnce(false)
+ expect(await waitForSocketPushHandoff(event, 'host', new AbortController().signal)).toBe(true)
+ expect(active).toBeUndefined()
+})
+
+it('leaves hosts without a registered push token on local delivery', async () => {
+ expect(
+ await waitForSocketPushHandoff(event, 'unregistered-host', new AbortController().signal)
+ ).toBe(true)
+ expect(active).toBeUndefined()
+})
diff --git a/mobile/src/notifications/socket-push-delivery-handoff.ts b/mobile/src/notifications/socket-push-delivery-handoff.ts
new file mode 100644
index 00000000000..25dc27b00a3
--- /dev/null
+++ b/mobile/src/notifications/socket-push-delivery-handoff.ts
@@ -0,0 +1,49 @@
+import { AppState } from 'react-native'
+import { loadRemotePushEnabled, loadRemotePushHostRegistrations } from '../storage/preferences'
+import { readPresentedPushSeenKeys } from './push-tray-seen-seed'
+import { seenKeyForEvent } from './notification-reconnect-catchup'
+import type { NotificationEvent } from './local-notification-scheduling'
+
+function waitUntilActive(signal: AbortSignal): Promise {
+ if (AppState.currentState === 'active' || signal.aborted) {
+ return Promise.resolve()
+ }
+ return new Promise((resolve) => {
+ const finish = () => {
+ subscription.remove()
+ signal.removeEventListener('abort', finish)
+ resolve()
+ }
+ const subscription = AppState.addEventListener('change', (state) => {
+ if (state === 'active') {
+ finish()
+ }
+ })
+ signal.addEventListener('abort', finish, { once: true })
+ if (signal.aborted || AppState.currentState === 'active') {
+ finish()
+ }
+ })
+}
+
+export async function waitForSocketPushHandoff(
+ event: NotificationEvent,
+ hostId: string,
+ signal: AbortSignal
+): Promise {
+ if (!(await loadRemotePushEnabled())) {
+ return true
+ }
+ const registrations = await loadRemotePushHostRegistrations()
+ if (!registrations.registeredHostIds.includes(hostId)) {
+ return true
+ }
+ // iOS can keep the socket alive while backgrounded; let APNs own that interval.
+ await waitUntilActive(signal)
+ if (signal.aborted) {
+ return false
+ }
+ const key = seenKeyForEvent(event)
+ const presented = await readPresentedPushSeenKeys(hostId)
+ return !presented.some((push) => push.key === key && push.epoch === event.notificationEpoch)
+}
diff --git a/mobile/src/notifications/use-remote-push-capable-hosts.test.tsx b/mobile/src/notifications/use-remote-push-capable-hosts.test.tsx
new file mode 100644
index 00000000000..511406b5c06
--- /dev/null
+++ b/mobile/src/notifications/use-remote-push-capable-hosts.test.tsx
@@ -0,0 +1,176 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { loadHostCatalog } from '../transport/host-store'
+import type { HostCatalogEntry } from '../transport/types'
+import type { RpcClient } from '../transport/rpc-client'
+import { startRuntimeCapabilityProbe } from '../transport/runtime-capability-probe'
+import { useAllHostClients } from '../transport/use-all-host-clients'
+import {
+ useRemotePushCapableHosts,
+ type RemotePushHostSupport
+} from './use-remote-push-capable-hosts'
+
+vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn() }))
+vi.mock('../transport/use-all-host-clients', () => ({ useAllHostClients: vi.fn() }))
+vi.mock('../transport/runtime-capability-probe', () => ({
+ startRuntimeCapabilityProbe: vi.fn()
+}))
+
+// The real module reaches expo-notifications and the preference store for the token
+// path; only the capability string matters here.
+vi.mock('./push-registration', () => ({
+ NOTIFICATIONS_REMOTE_PUSH_CAPABILITY: 'notifications.remote-push.v1'
+}))
+
+const CAPABILITY = 'notifications.remote-push.v1'
+
+type ClientEntry = { hostId: string; client: RpcClient; state: string }
+
+/** Distinct object per host, so identity changes are the thing under test. */
+function clientFor(hostId: string): RpcClient {
+ return { hostId } as unknown as RpcClient
+}
+
+let renderer: ReactTestRenderer | null = null
+let latest: RemotePushHostSupport = { supported: false, resolved: false }
+const answerByHostId = new Map void>()
+const stopProbe = vi.fn()
+
+function Harness(): null {
+ latest = useRemotePushCapableHosts()
+ return null
+}
+
+async function mount(): Promise {
+ await act(async () => {
+ renderer = create(createElement(Harness))
+ await Promise.resolve()
+ })
+}
+
+async function setClients(entries: readonly ClientEntry[]): Promise {
+ vi.mocked(useAllHostClients).mockReturnValue(entries as never)
+ await act(async () => {
+ renderer?.update(createElement(Harness))
+ await Promise.resolve()
+ })
+}
+
+async function answer(hostId: string, capabilities: readonly string[]): Promise {
+ await act(async () => {
+ answerByHostId.get(hostId)?.(capabilities)
+ await Promise.resolve()
+ })
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ answerByHostId.clear()
+ latest = { supported: false, resolved: false }
+ vi.mocked(useAllHostClients).mockReturnValue([] as never)
+ vi.mocked(startRuntimeCapabilityProbe).mockImplementation((client, onCapabilities) => {
+ answerByHostId.set((client as unknown as { hostId: string }).hostId, onCapabilities)
+ return stopProbe
+ })
+ vi.mocked(loadHostCatalog).mockResolvedValue([
+ { id: 'host-1', publicKeyB64: 'k1' },
+ { id: 'host-2', publicKeyB64: 'k2' }
+ ] as unknown as HostCatalogEntry[])
+})
+
+afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+})
+
+describe('useRemotePushCapableHosts', () => {
+ it('stays unresolved when the host catalog cannot be read', async () => {
+ vi.mocked(loadHostCatalog).mockRejectedValue(new Error('keychain locked'))
+
+ await mount()
+
+ // Resolving here would render "Update your desktop app" at someone whose desktop
+ // is already current, on the strength of a catalog read that simply failed.
+ expect(latest).toEqual({ supported: false, resolved: false })
+ })
+
+ it('waits for every connected host before answering', async () => {
+ await mount()
+ await setClients([
+ { hostId: 'host-1', client: clientFor('host-1'), state: 'connected' },
+ { hostId: 'host-2', client: clientFor('host-2'), state: 'connected' }
+ ])
+
+ await answer('host-1', [CAPABILITY])
+ expect(latest.resolved).toBe(false)
+
+ await answer('host-2', ['some-other.v1'])
+ expect(latest).toEqual({ supported: true, resolved: true })
+ })
+
+ it('keeps the answer of a host that has since disconnected', async () => {
+ await mount()
+ const client = clientFor('host-1')
+ await setClients([{ hostId: 'host-1', client, state: 'connected' }])
+ await answer('host-1', [CAPABILITY])
+
+ await setClients([{ hostId: 'host-1', client, state: 'connecting' }])
+
+ expect(latest).toEqual({ supported: true, resolved: true })
+ })
+
+ it('resolves immediately when nothing is paired', async () => {
+ vi.mocked(loadHostCatalog).mockResolvedValue([])
+
+ await mount()
+
+ expect(latest).toEqual({ supported: false, resolved: true })
+ })
+
+ it('leaves a running probe alone when another host changes state', async () => {
+ await mount()
+ const first = clientFor('host-1')
+ await setClients([{ hostId: 'host-1', client: first, state: 'connected' }])
+ expect(startRuntimeCapabilityProbe).toHaveBeenCalledTimes(1)
+
+ // useAllHostClients rebuilds its array on every connection tick, so a plain
+ // dependency on it would tear down and restart host-1's probe here.
+ await setClients([
+ { hostId: 'host-1', client: first, state: 'connected' },
+ { hostId: 'host-2', client: clientFor('host-2'), state: 'connecting' }
+ ])
+ await setClients([
+ { hostId: 'host-1', client: first, state: 'connected' },
+ { hostId: 'host-2', client: clientFor('host-2'), state: 'connected' }
+ ])
+
+ expect(stopProbe).not.toHaveBeenCalled()
+ expect(
+ vi.mocked(startRuntimeCapabilityProbe).mock.calls.map(([client]) => client)
+ ).toHaveLength(2)
+ })
+
+ it('restarts the probe when a reconnect replaces the host client', async () => {
+ await mount()
+ await setClients([{ hostId: 'host-1', client: clientFor('host-1'), state: 'connected' }])
+
+ await setClients([{ hostId: 'host-1', client: clientFor('host-1'), state: 'connected' }])
+
+ expect(stopProbe).toHaveBeenCalledTimes(1)
+ expect(startRuntimeCapabilityProbe).toHaveBeenCalledTimes(2)
+ })
+
+ it('ignores an answer from a host the catalog no longer lists', async () => {
+ await mount()
+ await setClients([
+ { hostId: 'host-ghost', client: clientFor('host-ghost'), state: 'connected' }
+ ])
+
+ await answer('host-ghost', [CAPABILITY])
+
+ // An unpaired desktop cannot push to this phone, so its vote must not offer
+ // the switch — nor count as the answer that resolves the section.
+ expect(latest).toEqual({ supported: false, resolved: false })
+ })
+})
diff --git a/mobile/src/notifications/use-remote-push-capable-hosts.ts b/mobile/src/notifications/use-remote-push-capable-hosts.ts
new file mode 100644
index 00000000000..a89ed79ff6b
--- /dev/null
+++ b/mobile/src/notifications/use-remote-push-capable-hosts.ts
@@ -0,0 +1,105 @@
+import { useEffect, useRef, useState } from 'react'
+import { loadHostCatalog } from '../transport/host-store'
+import type { RpcClient } from '../transport/rpc-client'
+import { startRuntimeCapabilityProbe } from '../transport/runtime-capability-probe'
+import { useAllHostClients } from '../transport/use-all-host-clients'
+import { NOTIFICATIONS_REMOTE_PUSH_CAPABILITY } from './push-registration'
+
+export type RemotePushHostSupport = {
+ /** At least one paired host advertises `notifications.remote-push.v1`. */
+ supported: boolean
+ /** Whether the answer above is final rather than "nobody has replied yet". */
+ resolved: boolean
+}
+
+/**
+ * Whether background push can be offered at all. The desktop advertises the
+ * capability in `status.get`, so the answer needs a connected host — until one
+ * replies the screen must stay silent rather than tell someone to update a
+ * desktop that is already current.
+ */
+export function useRemotePushCapableHosts(): RemotePushHostSupport {
+ const [hostIds, setHostIds] = useState([])
+ const [hostsLoaded, setHostsLoaded] = useState(false)
+ const [supportedByHostId, setSupportedByHostId] = useState>({})
+ const probesRef = useRef(new Map void }>())
+
+ useEffect(() => {
+ let cancelled = false
+ void loadHostCatalog()
+ .then((hosts) => {
+ if (!cancelled) {
+ setHostIds(hosts.map((host) => host.id))
+ setHostsLoaded(true)
+ }
+ })
+ // Why nothing on failure: an unread catalog marked loaded resolves the answer as
+ // "no paired host supports push", which tells the user to update a current desktop.
+ .catch(() => {})
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ const clients = useAllHostClients(hostIds)
+
+ // Why pruned rather than left: an answer for a host that is no longer paired is a
+ // vote from a desktop this phone cannot receive a push from.
+ useEffect(() => {
+ setSupportedByHostId((previous) => {
+ const kept = Object.entries(previous).filter(([hostId]) => hostIds.includes(hostId))
+ return kept.length === Object.keys(previous).length ? previous : Object.fromEntries(kept)
+ })
+ }, [hostIds])
+
+ // Why diffed by client identity rather than restarted on every `clients` value:
+ // useAllHostClients rebuilds the array on each connection tick, so a plain
+ // dependency tears down and re-runs every host's probe whenever any host moves.
+ useEffect(() => {
+ const connected = new Map(
+ clients
+ .filter((entry) => entry.state === 'connected')
+ .map((entry) => [entry.hostId, entry.client])
+ )
+ const probes = probesRef.current
+ for (const [hostId, probe] of probes) {
+ if (connected.get(hostId) !== probe.client) {
+ probe.stop()
+ probes.delete(hostId)
+ }
+ }
+ for (const [hostId, client] of connected) {
+ if (!probes.has(hostId)) {
+ const stop = startRuntimeCapabilityProbe(client, (capabilities) => {
+ setSupportedByHostId((previous) => ({
+ ...previous,
+ [hostId]: capabilities.includes(NOTIFICATIONS_REMOTE_PUSH_CAPABILITY)
+ }))
+ })
+ probes.set(hostId, { client, stop })
+ }
+ }
+ }, [clients])
+
+ useEffect(() => {
+ const probes = probesRef.current
+ return () => {
+ for (const probe of probes.values()) {
+ probe.stop()
+ }
+ probes.clear()
+ }
+ }, [])
+
+ const answeredHostIds = hostIds.filter((hostId) => hostId in supportedByHostId)
+ return {
+ supported: answeredHostIds.some((hostId) => supportedByHostId[hostId] === true),
+ // A connected host that has not answered yet is exactly the case the silence is
+ // for, so one outstanding probe holds the whole section back. Disconnected hosts
+ // do not: their earlier answer stands, and one that never answered never will.
+ resolved:
+ (hostsLoaded && hostIds.length === 0) ||
+ (answeredHostIds.length > 0 &&
+ clients.every((entry) => entry.state !== 'connected' || entry.hostId in supportedByHostId))
+ }
+}
diff --git a/mobile/src/session/mobile-native-chat-session-option-persistence.test.ts b/mobile/src/session/mobile-native-chat-session-option-persistence.test.ts
new file mode 100644
index 00000000000..28be1af40e2
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-session-option-persistence.test.ts
@@ -0,0 +1,99 @@
+import { describe, expect, it, vi } from 'vitest'
+import {
+ applyNativeChatSessionOptionSettingsMutation,
+ resolveStructuredLaunchSeedOptions
+} from '../../../src/shared/native-chat-session-option-defaults'
+import type { PersistedNativeChatSessionOptions } from '../../../src/shared/native-chat-session-options'
+import type { RpcClient } from '../transport/rpc-client'
+import { persistMobileStructuredOptionPicks } from './mobile-native-chat-session-option-persistence'
+
+function hostClient(initial?: PersistedNativeChatSessionOptions) {
+ let stored = initial
+ const sendRequest = vi.fn(async (method: string, params?: unknown) => {
+ expect(method).toBe('settings.mutateNativeChatSessionOptions')
+ const next = applyNativeChatSessionOptionSettingsMutation(
+ stored,
+ params as Parameters[1]
+ )
+ stored = next ?? stored
+ return { id: '2', ok: true as const, result: null, _meta: { runtimeId: 'host' } }
+ })
+ return { client: { sendRequest } as unknown as RpcClient, sendRequest, read: () => stored }
+}
+
+describe('persistMobileStructuredOptionPicks', () => {
+ it('writes the pick to the host record a later launch seeds from', async () => {
+ const host = hostClient()
+ await persistMobileStructuredOptionPicks({
+ client: host.client,
+ agent: 'codex',
+ picks: [{ modelId: 'gpt-fast', optionId: 'effort', value: 'low' }]
+ })
+ expect(resolveStructuredLaunchSeedOptions(host.read(), 'codex')).toEqual({
+ model: 'gpt-fast',
+ effort: 'low'
+ })
+ })
+
+ it('merges onto the host record instead of replacing another agent', async () => {
+ const host = hostClient({
+ claude: { model: 'opus', valuesByModel: { opus: { effort: 'high' } } }
+ })
+ await persistMobileStructuredOptionPicks({
+ client: host.client,
+ agent: 'codex',
+ picks: [{ modelId: 'gpt-fast', optionId: 'model', value: 'gpt-fast' }]
+ })
+ expect(resolveStructuredLaunchSeedOptions(host.read(), 'claude')).toEqual({
+ model: 'opus',
+ effort: 'high'
+ })
+ })
+
+ it('sends concurrent deltas that preserve both picks on the host', async () => {
+ const host = hostClient()
+ const first = persistMobileStructuredOptionPicks({
+ client: host.client,
+ agent: 'codex',
+ picks: [{ modelId: 'gpt-fast', optionId: 'effort', value: 'low' }]
+ })
+ const second = persistMobileStructuredOptionPicks({
+ client: host.client,
+ agent: 'claude',
+ picks: [{ modelId: 'opus', optionId: 'effort', value: 'high' }]
+ })
+ await Promise.all([first, second])
+ expect(resolveStructuredLaunchSeedOptions(host.read(), 'codex')).toEqual({
+ model: 'gpt-fast',
+ effort: 'low'
+ })
+ expect(resolveStructuredLaunchSeedOptions(host.read(), 'claude')).toEqual({
+ model: 'opus',
+ effort: 'high'
+ })
+ })
+
+ it('stays silent without a host or without picks', async () => {
+ const host = hostClient()
+ await persistMobileStructuredOptionPicks({ client: null, agent: 'codex', picks: [] })
+ await persistMobileStructuredOptionPicks({ client: host.client, agent: 'codex', picks: [] })
+ expect(host.sendRequest).not.toHaveBeenCalled()
+ })
+
+ it('uses one targeted host mutation instead of a settings read-modify-write', async () => {
+ const host = hostClient()
+ await persistMobileStructuredOptionPicks({
+ client: host.client,
+ agent: 'codex',
+ picks: [{ modelId: 'gpt-fast', optionId: 'model', value: 'gpt-fast' }]
+ })
+ expect(host.sendRequest).toHaveBeenCalledExactlyOnceWith(
+ 'settings.mutateNativeChatSessionOptions',
+ {
+ type: 'apply-picks',
+ agent: 'codex',
+ picks: [{ modelId: 'gpt-fast', optionId: 'model', value: 'gpt-fast' }]
+ }
+ )
+ })
+})
diff --git a/mobile/src/session/mobile-native-chat-session-option-persistence.ts b/mobile/src/session/mobile-native-chat-session-option-persistence.ts
new file mode 100644
index 00000000000..aaad5a92f51
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-session-option-persistence.ts
@@ -0,0 +1,25 @@
+import type { AgentType } from '../../../src/shared/agent-status-types'
+import type { StructuredSessionOptionPick } from '../../../src/shared/structured-agent-session-options'
+import type { RpcClient } from '../transport/rpc-client'
+
+/** The host owns the record a later launch seeds from, so a phone-side pick writes there
+ * rather than to any client-local store. Best-effort: a failed write only costs the
+ * next session its remembered start. */
+export function persistMobileStructuredOptionPicks(args: {
+ client: RpcClient | null
+ agent: AgentType
+ picks: readonly StructuredSessionOptionPick[]
+}): Promise {
+ const { agent, client, picks } = args
+ if (!client || picks.length === 0) {
+ return Promise.resolve()
+ }
+ return client
+ .sendRequest('settings.mutateNativeChatSessionOptions', {
+ type: 'apply-picks',
+ agent,
+ picks
+ })
+ .then(() => undefined)
+ .catch(() => undefined)
+}
diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts
index 108275223be..7c8651ffda6 100644
--- a/mobile/src/session/use-mobile-structured-agent-options.ts
+++ b/mobile/src/session/use-mobile-structured-agent-options.ts
@@ -15,6 +15,7 @@ import {
commitStructuredAgentSessionOption,
commitStructuredAgentSessionOptionValues,
createStructuredAgentSessionOptionState,
+ structuredAgentSessionOptionPicks,
structuredAgentSessionOptionSnapshot
} from '../../../src/shared/structured-agent-session-options'
import type { RpcClient } from '../transport/rpc-client'
@@ -22,6 +23,7 @@ import {
callAgentSession,
type StructuredAgentSessionMutate
} from './mobile-structured-agent-session-rpc'
+import { persistMobileStructuredOptionPicks } from './mobile-native-chat-session-option-persistence'
type StructuredOptionsController = {
optionSnapshot: SessionOptionDescriptor[]
@@ -101,14 +103,22 @@ export function useMobileStructuredAgentOptions(args: {
return result.status !== 'rejected'
}
if (result.status === 'accepted') {
+ const committed = result.value.options ?? { [id]: value }
setOptionState((current) =>
current.record === targetRecord && result.sameFence
- ? commitStructuredAgentSessionOptionValues(
- current,
- result.value.options ?? { [id]: value }
- )
+ ? commitStructuredAgentSessionOptionValues(current, committed)
: current
)
+ // Only an accepted pick: an `unknown` outcome commits optimistically to the
+ // visible record, and remembering one the provider refused would seed a
+ // launch the user never chose.
+ if (agent === 'claude' || agent === 'codex') {
+ void persistMobileStructuredOptionPicks({
+ client,
+ agent,
+ picks: structuredAgentSessionOptionPicks(optionState, committed)
+ })
+ }
return true
}
if (result.status === 'unknown') {
@@ -128,7 +138,7 @@ export function useMobileStructuredAgentOptions(args: {
)
}
},
- [mutate, optionState]
+ [agent, client, mutate, optionState]
)
const invokeStructuredOption = useCallback(async () => false, [])
diff --git a/mobile/src/session/use-mobile-structured-agent-session.test.tsx b/mobile/src/session/use-mobile-structured-agent-session.test.tsx
index 83562839363..d888e74c921 100644
--- a/mobile/src/session/use-mobile-structured-agent-session.test.tsx
+++ b/mobile/src/session/use-mobile-structured-agent-session.test.tsx
@@ -138,7 +138,7 @@ function runningStatusItem(): AgentJournalRenderItem {
}
}
-function defaultSendRequest(method: string, params?: Record) {
+async function defaultSendRequest(method: string, params?: Record) {
if (method === 'agentSession.send') {
return ok({
ok: true,
@@ -420,6 +420,11 @@ describe('useMobileStructuredAgentSession', () => {
}),
expect.any(Object)
)
+ expect(sendRequest).toHaveBeenCalledWith('settings.mutateNativeChatSessionOptions', {
+ type: 'apply-picks',
+ agent: 'codex',
+ picks: [{ modelId: 'gpt-fast', optionId: 'model', value: 'gpt-fast' }]
+ })
await act(async () => {
expect(await hook.respondPermission(hook.permission!.options[0]!.send)).toBe(true)
diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts
index 5173ac5bc8a..37d237f7bd7 100644
--- a/mobile/src/storage/preferences.ts
+++ b/mobile/src/storage/preferences.ts
@@ -1,4 +1,14 @@
+import {
+ loadNotificationDeliveryPreferences,
+ notificationPreferencesFilter,
+ saveNotificationDeliveryPreferences
+} from '../notifications/notification-delivery-preferences'
import AsyncStorage from '@react-native-async-storage/async-storage'
+import {
+ MOBILE_PUSH_AGENT_STATES,
+ type MobilePushAgentState,
+ type MobilePushFilter
+} from '../../../src/shared/mobile-push-contract'
const PINS_PREFIX = 'orca:pins:'
const NOTIF_KEY = 'orca:pushNotificationsEnabled'
@@ -30,6 +40,98 @@ export async function savePushNotificationsEnabled(enabled: boolean): Promise {
+ try {
+ return (await AsyncStorage.getItem(REMOTE_PUSH_KEY)) === 'true'
+ } catch {
+ return false
+ }
+}
+
+export async function saveRemotePushEnabled(enabled: boolean): Promise {
+ await AsyncStorage.setItem(REMOTE_PUSH_KEY, String(enabled))
+}
+
+function remotePushAgentStates(value: unknown): RemotePushAgentState[] {
+ return stringArray(value).filter((state): state is RemotePushAgentState =>
+ (MOBILE_PUSH_AGENT_STATES as readonly string[]).includes(state)
+ )
+}
+
+// Both states default on; an absent key is a device that never opened the section.
+export async function loadRemotePushAgentStates(): Promise {
+ try {
+ const raw = await AsyncStorage.getItem(REMOTE_PUSH_AGENT_STATES_KEY)
+ return raw === null ? MOBILE_PUSH_AGENT_STATES : remotePushAgentStates(JSON.parse(raw))
+ } catch {
+ return MOBILE_PUSH_AGENT_STATES
+ }
+}
+
+export async function saveRemotePushAgentStates(
+ states: readonly RemotePushAgentState[]
+): Promise {
+ const current = await loadNotificationDeliveryPreferences()
+ await saveNotificationDeliveryPreferences({
+ ...current,
+ followDesktop: false,
+ taskFinished: states.includes('finished'),
+ needsInput: states.includes('needs-input')
+ })
+ await AsyncStorage.setItem(REMOTE_PUSH_AGENT_STATES_KEY, JSON.stringify([...states]))
+}
+
+export async function loadRemotePushFilter(): Promise {
+ return notificationPreferencesFilter(await loadNotificationDeliveryPreferences())
+}
+
+// Why persisted: switching off while a host is offline leaves a token the gateway
+// would still push to. The pending list is the phone's side of the desktop's
+// unregister outbox — it survives a restart so the retry actually happens.
+export type RemotePushHostRegistrations = {
+ readonly registeredHostIds: readonly string[]
+ readonly pendingUnregisterHostIds: readonly string[]
+}
+
+const EMPTY_REMOTE_PUSH_HOST_REGISTRATIONS: RemotePushHostRegistrations = {
+ registeredHostIds: [],
+ pendingUnregisterHostIds: []
+}
+
+export async function loadRemotePushHostRegistrations(): Promise {
+ try {
+ const raw = await AsyncStorage.getItem(REMOTE_PUSH_HOST_REGISTRATIONS_KEY)
+ if (!raw) {
+ return EMPTY_REMOTE_PUSH_HOST_REGISTRATIONS
+ }
+ const parsed = JSON.parse(raw) as Record
+ return {
+ registeredHostIds: stringArray(parsed.registeredHostIds),
+ pendingUnregisterHostIds: stringArray(parsed.pendingUnregisterHostIds)
+ }
+ } catch {
+ return EMPTY_REMOTE_PUSH_HOST_REGISTRATIONS
+ }
+}
+
+export async function saveRemotePushHostRegistrations(
+ value: RemotePushHostRegistrations
+): Promise {
+ await AsyncStorage.setItem(REMOTE_PUSH_HOST_REGISTRATIONS_KEY, JSON.stringify(value))
+}
+
const TEXT_SCALE_KEY = 'orca:terminalTextScale'
// Why: the mobile terminal fits the desktop's full column count to the phone
diff --git a/mobile/src/transport/host-removal-lifecycle.test.ts b/mobile/src/transport/host-removal-lifecycle.test.ts
index 6c96ef1c446..26d6569afb9 100644
--- a/mobile/src/transport/host-removal-lifecycle.test.ts
+++ b/mobile/src/transport/host-removal-lifecycle.test.ts
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const removeHostMock = vi.hoisted(() => vi.fn())
+const unregisterPushMock = vi.hoisted(() => vi.fn(async () => {}))
const asyncStorage = vi.hoisted(() => ({
getItem: vi.fn(async () => null),
setItem: vi.fn(async () => undefined),
@@ -16,6 +17,12 @@ vi.mock('./host-store', () => ({
removeHost: (hostId: string) => removeHostMock(hostId)
}))
+// Why mocked: the real module reaches expo-notifications for the device token, which
+// no node test environment can load.
+vi.mock('../notifications/push-registration', () => ({
+ unregisterPushForRemovedHost: (hostId: string) => unregisterPushMock(hostId)
+}))
+
import { removeHostAndCloseClient } from './host-removal-lifecycle'
import {
getHostNotificationSession,
@@ -25,6 +32,7 @@ import {
describe('host removal lifecycle', () => {
beforeEach(() => {
removeHostMock.mockReset()
+ unregisterPushMock.mockClear()
asyncStorage.removeItem.mockClear()
resetHostNotificationSessionsForTests()
})
@@ -75,6 +83,27 @@ describe('host removal lifecycle', () => {
expect(afterRemoval.lastDeliveredEpoch).toBeNull()
})
+ it('drops the gateway push registration before the credentials it needs are gone', async () => {
+ removeHostMock.mockResolvedValue(undefined)
+
+ await removeHostAndCloseClient('host-1', vi.fn())
+
+ expect(unregisterPushMock).toHaveBeenCalledWith('host-1')
+ expect(unregisterPushMock.mock.invocationCallOrder[0]).toBeLessThan(
+ removeHostMock.mock.invocationCallOrder[0]
+ )
+ })
+
+ it('still removes the host when the push unregister cannot land', async () => {
+ removeHostMock.mockResolvedValue(undefined)
+ unregisterPushMock.mockRejectedValueOnce(new Error('socket closed'))
+ const closeHostClient = vi.fn()
+
+ await removeHostAndCloseClient('host-1', closeHostClient)
+
+ expect(closeHostClient).toHaveBeenCalledWith('host-1')
+ })
+
it('erases the persisted watermark, not just the in-memory session', async () => {
// Why separately from the test above: the session is process-local, the
// watermark is not. Retiring only the session lets a re-pair of the same host
diff --git a/mobile/src/transport/host-removal-lifecycle.ts b/mobile/src/transport/host-removal-lifecycle.ts
index cd0a09cb67e..488e4e3f9fe 100644
--- a/mobile/src/transport/host-removal-lifecycle.ts
+++ b/mobile/src/transport/host-removal-lifecycle.ts
@@ -2,12 +2,16 @@ import {
clearWatermark,
forgetHostNotificationSession
} from '../notifications/notification-reconnect-catchup'
+import { unregisterPushForRemovedHost } from '../notifications/push-registration'
import { removeHost } from './host-store'
export async function removeHostAndCloseClient(
hostId: string,
forgetHostClient: (hostId: string) => void
): Promise {
+ // Why before removeHost: the unregister needs the still-authenticated client, and
+ // the desktop's own revoke path covers the case where this call cannot land.
+ await unregisterPushForRemovedHost(hostId).catch(() => {})
// Why: closing before the metadata commit can strand a still-paired host on
// storage failure; closing immediately after success prevents socket leaks.
await removeHost(hostId)
diff --git a/package.json b/package.json
index 7b27dffc8c7..25152e84849 100644
--- a/package.json
+++ b/package.json
@@ -105,7 +105,7 @@
"test:e2e:workspace-session-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-quit-relaunch-session.spec.ts tests/e2e/golden-terminal-file-link.spec.ts tests/e2e/golden-worktree-create-switch.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:multi-client-navigation": "node config/scripts/run-multi-client-navigation-e2e.mjs",
"test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
- "test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts tests/e2e/terminal-webgl-atlas-budget.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
+ "test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts tests/e2e/terminal-webgl-atlas-budget.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --project electron-headful --workers=1",
"test:e2e:source-control-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-file-open-edit-save.spec.ts tests/e2e/golden-source-control-commit.spec.ts tests/e2e/golden-source-control-open-diff.spec.ts --grep @golden --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:posix-profile-index-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-posix-fresh-startup.spec.ts tests/e2e/golden-posix-profile-index-fsync.spec.ts --grep @posix-profile-index-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:windows-fresh-startup-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-windows-fresh-startup.spec.ts --grep @windows-fresh-startup-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
diff --git a/src/cli/handlers/orchestration-caller-identity-cli.test.ts b/src/cli/handlers/orchestration-caller-identity-cli.test.ts
new file mode 100644
index 00000000000..dc272e4b94b
--- /dev/null
+++ b/src/cli/handlers/orchestration-caller-identity-cli.test.ts
@@ -0,0 +1,377 @@
+/**
+ * How the orchestration CLI decides WHO is speaking.
+ *
+ * Split out of `orchestration.test.ts`, which sat exactly on the test-file line ceiling: these two
+ * suites are one subject — the coordinator and task-creator identity a command carries — and both
+ * exercise the env-handle validation and pane-remint chain rather than flag-to-param mapping.
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const callMock = vi.fn()
+const getTerminalHandleMock = vi.hoisted(() => vi.fn())
+const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE
+const originalPaneKey = process.env.ORCA_PANE_KEY
+// Why: isolate the handler's flag-to-param mapping; printResult only writes output.
+vi.mock('../format', () => ({ printResult: vi.fn() }))
+vi.mock('../selectors', () => ({ getTerminalHandle: getTerminalHandleMock }))
+
+import { ORCHESTRATION_HANDLERS } from './orchestration'
+import { RuntimeClientError } from '../runtime-client'
+
+function staleHandleError(): RuntimeClientError {
+ return new RuntimeClientError('terminal_handle_stale', 'terminal_handle_stale')
+}
+
+// Queues the stale-handle remint chain shared by coordinator commands:
+// `terminal.resolveIdentity` answers not-live → resolvePane returns liveHandle → downstream RPC.
+function stubStaleHandleRemint(liveHandle: string, downstream: unknown): void {
+ callMock
+ .mockResolvedValueOnce(notLiveIdentity())
+ .mockResolvedValueOnce({ result: { terminal: { handle: liveHandle } } })
+ .mockResolvedValueOnce(downstream)
+}
+
+// Queues a not-live identity followed by a resolvePane remint that fails with `error`.
+function stubStaleHandleRemintFailure(error: RuntimeClientError): void {
+ callMock.mockResolvedValueOnce(notLiveIdentity()).mockRejectedValueOnce(error)
+}
+
+/** What the runtime answers for a handle whose leaf check reports `terminal_handle_stale`. */
+function notLiveIdentity(): { result: { identity: { live: false } } } {
+ return { result: { identity: { live: false } } }
+}
+
+function liveIdentity(handle: string): { result: { identity: { handle: string; live: true } } } {
+ return { result: { identity: { handle, live: true } } }
+}
+
+afterEach(() => {
+ getTerminalHandleMock.mockReset()
+ if (originalTerminalHandle === undefined) {
+ delete process.env.ORCA_TERMINAL_HANDLE
+ } else {
+ process.env.ORCA_TERMINAL_HANDLE = originalTerminalHandle
+ }
+ if (originalPaneKey === undefined) {
+ delete process.env.ORCA_PANE_KEY
+ } else {
+ process.env.ORCA_PANE_KEY = originalPaneKey
+ }
+})
+
+describe('orchestration dispatch coordinator handle', () => {
+ beforeEach(() => {
+ callMock.mockReset()
+ getTerminalHandleMock.mockReset()
+ delete process.env.ORCA_TERMINAL_HANDLE
+ delete process.env.ORCA_PANE_KEY
+ })
+
+ const invokeDispatch = (flags: Map) =>
+ ORCHESTRATION_HANDLERS['orchestration dispatch']({
+ flags,
+ client: { call: callMock },
+ cwd: '/tmp/repo',
+ json: true
+ } as never)
+
+ const invokeDispatchShow = (flags: Map) =>
+ ORCHESTRATION_HANDLERS['orchestration dispatch-show']({
+ flags,
+ client: { call: callMock },
+ cwd: '/tmp/repo',
+ json: true
+ } as never)
+
+ const invokeRun = (flags: Map) =>
+ ORCHESTRATION_HANDLERS['orchestration coordinator-start']({
+ flags,
+ client: { call: callMock },
+ cwd: '/tmp/repo',
+ json: true
+ } as never)
+
+ it('remints a stale coordinator env handle from the caller pane key', async () => {
+ process.env.ORCA_TERMINAL_HANDLE = 'term_stale_coord'
+ process.env.ORCA_PANE_KEY = 'tab_coord:leaf_coord'
+ stubStaleHandleRemint('term_live_coord', {
+ result: { dispatch: { id: 'ctx_1', task_id: 'task_1', status: 'dispatched' } }
+ })
+ getTerminalHandleMock.mockRejectedValue(new Error('active terminal fallback is unsafe'))
+
+ await invokeDispatch(
+ new Map([
+ ['task', 'task_1'],
+ ['to', 'term_worker'],
+ ['inject', true]
+ ])
+ )
+
+ expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.resolveIdentity', {
+ terminal: 'term_stale_coord'
+ })
+ expect(callMock).toHaveBeenNthCalledWith(2, 'terminal.resolvePane', {
+ paneKey: 'tab_coord:leaf_coord'
+ })
+ expect(getTerminalHandleMock).not.toHaveBeenCalled()
+ expect(callMock).toHaveBeenNthCalledWith(3, 'orchestration.dispatch', {
+ task: 'task_1',
+ to: 'term_worker',
+ from: 'term_live_coord',
+ inject: true,
+ dryRun: undefined,
+ returnPreamble: undefined,
+ devMode: false
+ })
+ })
+
+ it('rejects stale coordinator env handles when the caller pane cannot be proven', async () => {
+ process.env.ORCA_TERMINAL_HANDLE = 'term_stale_coord'
+ callMock.mockRejectedValueOnce(staleHandleError())
+ getTerminalHandleMock.mockResolvedValue('term_wrong_active')
+
+ await expect(
+ invokeDispatch(
+ new Map([
+ ['task', 'task_1'],
+ ['to', 'term_worker']
+ ])
+ )
+ ).rejects.toMatchObject({
+ code: 'no_active_sender_terminal'
+ })
+
+ expect(callMock).toHaveBeenCalledTimes(1)
+ expect(getTerminalHandleMock).not.toHaveBeenCalled()
+ })
+
+ it('propagates unexpected caller pane remint failures for coordinator commands', async () => {
+ process.env.ORCA_TERMINAL_HANDLE = 'term_stale_coord'
+ process.env.ORCA_PANE_KEY = 'tab_coord:leaf_coord'
+ stubStaleHandleRemintFailure(
+ new RuntimeClientError('runtime_unavailable', 'runtime_unavailable')
+ )
+ getTerminalHandleMock.mockResolvedValue('term_wrong_active')
+
+ await expect(
+ invokeDispatch(
+ new Map([
+ ['task', 'task_1'],
+ ['to', 'term_worker']
+ ])
+ )
+ ).rejects.toMatchObject({
+ code: 'runtime_unavailable'
+ })
+
+ expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.resolveIdentity', {
+ terminal: 'term_stale_coord'
+ })
+ expect(callMock).toHaveBeenNthCalledWith(2, 'terminal.resolvePane', {
+ paneKey: 'tab_coord:leaf_coord'
+ })
+ expect(callMock).toHaveBeenCalledTimes(2)
+ expect(getTerminalHandleMock).not.toHaveBeenCalled()
+ })
+
+ it('uses a live coordinator handle for dispatch-show preamble previews', async () => {
+ process.env.ORCA_TERMINAL_HANDLE = 'term_stale_coord'
+ process.env.ORCA_PANE_KEY = 'tab_coord:leaf_coord'
+ stubStaleHandleRemint('term_live_coord', {
+ result: { dispatch: null, preamble: 'preamble' }
+ })
+ getTerminalHandleMock.mockRejectedValue(new Error('active terminal fallback is unsafe'))
+
+ await invokeDispatchShow(
+ new Map([
+ ['task', 'task_1'],
+ ['preamble', true]
+ ])
+ )
+
+ expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.resolveIdentity', {
+ terminal: 'term_stale_coord'
+ })
+ expect(callMock).toHaveBeenNthCalledWith(2, 'terminal.resolvePane', {
+ paneKey: 'tab_coord:leaf_coord'
+ })
+ expect(callMock).toHaveBeenNthCalledWith(3, 'orchestration.dispatchShow', {
+ task: 'task_1',
+ preamble: true,
+ from: 'term_live_coord',
+ devMode: false
+ })
+ })
+
+ it('retires the legacy coordinator command without runtime effects', async () => {
+ await expect(
+ invokeRun(new Map([['spec', 'run the plan']]))
+ ).rejects.toMatchObject({
+ code: 'orchestration_migration_required',
+ data: {
+ reason: 'command_retired',
+ effectsApplied: false,
+ nextCommandArgs: ['skills', 'get', 'orchestration', '--full']
+ }
+ })
+ expect(callMock).not.toHaveBeenCalled()
+ })
+})
+
+describe('orchestration task-create caller handle', () => {
+ beforeEach(() => {
+ callMock.mockReset()
+ getTerminalHandleMock.mockReset()
+ delete process.env.ORCA_TERMINAL_HANDLE
+ delete process.env.ORCA_PANE_KEY
+ })
+
+ const invokeTaskCreate = (flags: Map) =>
+ ORCHESTRATION_HANDLERS['orchestration task-create']({
+ flags,
+ client: { call: callMock },
+ cwd: '/tmp/repo',
+ json: true
+ } as never)
+
+ it('records a live env terminal handle as task creator', async () => {
+ process.env.ORCA_TERMINAL_HANDLE = 'term_creator'
+ callMock
+ .mockResolvedValueOnce(liveIdentity('term_creator'))
+ .mockResolvedValueOnce({ result: { task: { id: 'task_1', status: 'ready' } } })
+
+ await invokeTaskCreate(new Map([['spec', 'do work']]))
+
+ expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.resolveIdentity', {
+ terminal: 'term_creator'
+ })
+ expect(callMock).toHaveBeenNthCalledWith(2, 'orchestration.taskCreate', {
+ spec: 'do work',
+ taskTitle: undefined,
+ displayName: undefined,
+ deps: undefined,
+ parent: undefined,
+ run: undefined,
+ callerTerminalHandle: 'term_creator'
+ })
+ })
+
+ it('fails closed when a stale task creator handle cannot be reminted', async () => {
+ process.env.ORCA_TERMINAL_HANDLE = 'term_stale'
+ callMock.mockRejectedValueOnce(staleHandleError())
+ getTerminalHandleMock.mockResolvedValue('term_wrong_active')
+
+ await expect(
+ invokeTaskCreate(new Map([['spec', 'do work']]))
+ ).rejects.toMatchObject({ code: 'no_active_sender_terminal' })
+
+ expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.resolveIdentity', {
+ terminal: 'term_stale'
+ })
+ expect(getTerminalHandleMock).not.toHaveBeenCalled()
+ expect(callMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('propagates runtime unavailability while proving the bound coordinator', async () => {
+ process.env.ORCA_TERMINAL_HANDLE = 'term_creator'
+ callMock.mockRejectedValueOnce(
+ new RuntimeClientError('runtime_unavailable', 'runtime_unavailable')
+ )
+
+ await expect(
+ invokeTaskCreate(new Map([['spec', 'do work']]))
+ ).rejects.toMatchObject({ code: 'runtime_unavailable' })
+
+ expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.resolveIdentity', {
+ terminal: 'term_creator'
+ })
+ expect(getTerminalHandleMock).not.toHaveBeenCalled()
+ expect(callMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('propagates runtime unavailability while reminting the bound coordinator', async () => {
+ process.env.ORCA_TERMINAL_HANDLE = 'term_stale'
+ process.env.ORCA_PANE_KEY = 'tab_creator:leaf_creator'
+ stubStaleHandleRemintFailure(
+ new RuntimeClientError('runtime_unavailable', 'runtime_unavailable')
+ )
+ getTerminalHandleMock.mockResolvedValue('term_wrong_active')
+
+ await expect(
+ invokeTaskCreate(new Map([['spec', 'do work']]))
+ ).rejects.toMatchObject({ code: 'runtime_unavailable' })
+
+ expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.resolveIdentity', {
+ terminal: 'term_stale'
+ })
+ expect(callMock).toHaveBeenNthCalledWith(2, 'terminal.resolvePane', {
+ paneKey: 'tab_creator:leaf_creator'
+ })
+ expect(getTerminalHandleMock).not.toHaveBeenCalled()
+ expect(callMock).toHaveBeenCalledTimes(2)
+ })
+
+ it('propagates unexpected caller pane remint failures for task creation', async () => {
+ process.env.ORCA_TERMINAL_HANDLE = 'term_stale'
+ process.env.ORCA_PANE_KEY = 'tab_creator:leaf_creator'
+ stubStaleHandleRemintFailure(new RuntimeClientError('permission_denied', 'denied'))
+ getTerminalHandleMock.mockResolvedValue('term_wrong_active')
+
+ await expect(
+ invokeTaskCreate(new Map([['spec', 'do work']]))
+ ).rejects.toMatchObject({
+ code: 'permission_denied'
+ })
+
+ expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.resolveIdentity', {
+ terminal: 'term_stale'
+ })
+ expect(callMock).toHaveBeenNthCalledWith(2, 'terminal.resolvePane', {
+ paneKey: 'tab_creator:leaf_creator'
+ })
+ expect(callMock).toHaveBeenCalledTimes(2)
+ expect(getTerminalHandleMock).not.toHaveBeenCalled()
+ })
+
+ it('propagates unexpected env handle validation failures', async () => {
+ process.env.ORCA_TERMINAL_HANDLE = 'term_creator'
+ callMock.mockRejectedValueOnce(new RuntimeClientError('permission_denied', 'denied'))
+
+ await expect(
+ invokeTaskCreate(new Map([['spec', 'do work']]))
+ ).rejects.toMatchObject({
+ code: 'permission_denied'
+ })
+
+ expect(callMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('remints a stale task creator env handle from the caller pane key', async () => {
+ process.env.ORCA_TERMINAL_HANDLE = 'term_stale'
+ process.env.ORCA_PANE_KEY = 'tab_creator:leaf_creator'
+ stubStaleHandleRemint('term_live', {
+ result: { task: { id: 'task_1', status: 'ready' } }
+ })
+ getTerminalHandleMock.mockRejectedValue(new Error('active terminal fallback is unsafe'))
+
+ await invokeTaskCreate(new Map([['spec', 'do work']]))
+
+ expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.resolveIdentity', {
+ terminal: 'term_stale'
+ })
+ expect(callMock).toHaveBeenNthCalledWith(2, 'terminal.resolvePane', {
+ paneKey: 'tab_creator:leaf_creator'
+ })
+ expect(getTerminalHandleMock).not.toHaveBeenCalled()
+ expect(callMock).toHaveBeenNthCalledWith(3, 'orchestration.taskCreate', {
+ spec: 'do work',
+ taskTitle: undefined,
+ displayName: undefined,
+ deps: undefined,
+ parent: undefined,
+ run: undefined,
+ callerTerminalHandle: 'term_live'
+ })
+ })
+})
diff --git a/src/cli/handlers/orchestration-gate-cli.test.ts b/src/cli/handlers/orchestration-gate-cli.test.ts
index b4be315c155..a2793ac9323 100644
--- a/src/cli/handlers/orchestration-gate-cli.test.ts
+++ b/src/cli/handlers/orchestration-gate-cli.test.ts
@@ -72,7 +72,7 @@ describe('orchestration gate commands carry caller identity', () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_coord'
queueFixtures(
callMock,
- okFixture('req_show', { terminal: { handle: 'term_coord' } }),
+ okFixture('req_identity', { identity: { handle: 'term_coord', live: true } }),
okFixture('req_gate', { gate: { id: 'gate_1', task_id: 'task_1', status: 'pending' } })
)
@@ -91,8 +91,8 @@ describe('orchestration gate commands carry caller identity', () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_stale'
process.env.ORCA_PANE_KEY = 'tab_coord:leaf_coord'
callMock.mockImplementation(async (method: string) => {
- if (method === 'terminal.show') {
- throw new RuntimeClientError('terminal_handle_stale', 'stale')
+ if (method === 'terminal.resolveIdentity') {
+ return okFixture('req_identity', { identity: { handle: 'term_stale', live: false } })
}
if (method === 'terminal.resolvePane') {
return okFixture('req_pane', { terminal: { handle: 'term_live' } })
@@ -146,7 +146,7 @@ describe('orchestration gate commands carry caller identity', () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_coord'
queueFixtures(
callMock,
- okFixture('req_show', { terminal: { handle: 'term_coord' } }),
+ okFixture('req_identity', { identity: { handle: 'term_coord', live: true } }),
okFixture('req_list', { gates: [], count: 0 })
)
@@ -199,7 +199,9 @@ describe('orchestration gate commands carry caller identity', () => {
it('reports idempotent recovery when a mutation connection drops', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_coord'
callMock
- .mockResolvedValueOnce(okFixture('req_show', { terminal: { handle: 'term_coord' } }))
+ .mockResolvedValueOnce(
+ okFixture('req_identity', { identity: { handle: 'term_coord', live: true } })
+ )
.mockRejectedValueOnce(
new RuntimeClientError(
'runtime_unavailable',
diff --git a/src/cli/handlers/orchestration-task-create-cli.test.ts b/src/cli/handlers/orchestration-task-create-cli.test.ts
index 839ccabdcd6..8b5957782d2 100644
--- a/src/cli/handlers/orchestration-task-create-cli.test.ts
+++ b/src/cli/handlers/orchestration-task-create-cli.test.ts
@@ -27,7 +27,7 @@ describe('orchestration task-create CLI mapping', () => {
it('passes PowerShell-stripped deps through to the runtime', async () => {
callMock
- .mockResolvedValueOnce({ result: { terminal: { handle: 'term_creator' } } })
+ .mockResolvedValueOnce({ result: { identity: { handle: 'term_creator', live: true } } })
.mockResolvedValueOnce({ result: { task: { id: 'task_2', status: 'pending' } } })
await ORCHESTRATION_HANDLERS['orchestration task-create']({
diff --git a/src/cli/handlers/orchestration-worker-cli.test.ts b/src/cli/handlers/orchestration-worker-cli.test.ts
index f17420ab261..cddd36a4cf7 100644
--- a/src/cli/handlers/orchestration-worker-cli.test.ts
+++ b/src/cli/handlers/orchestration-worker-cli.test.ts
@@ -334,6 +334,59 @@ describe('orchestration worker-start CLI contract', () => {
).toContain('Warning: Terminal term_worker is running but could not be revealed.')
})
+ it('states the worker mode that actually ran, so a fallback is never silent', async () => {
+ callMock.mockResolvedValue({
+ result: {
+ taskId: 'task_1',
+ dispatchId: 'ctx_1',
+ state: 'ready',
+ mode: {
+ mode: 'terminal',
+ preferred: 'structured',
+ reason: 'reused_terminal',
+ detail:
+ 'Your default is a structured chat session, but --terminal reuses a running terminal agent; started a terminal agent worker instead.'
+ },
+ effects: [],
+ residualResources: []
+ }
+ })
+
+ await ORCHESTRATION_HANDLERS['orchestration worker-start']({
+ flags: new Map([
+ ['task', 'task_1'],
+ ['terminal', 'term_worker'],
+ ['from', 'term_coord']
+ ]),
+ client: { call: callMock },
+ cwd: '/tmp/repo',
+ json: false
+ } as never)
+
+ const formatter = vi.mocked(printResult).mock.calls[0]?.[2] as
+ | ((result: {
+ taskId: string
+ dispatchId: string
+ state: string
+ mode?: { mode: string; preferred: string; reason: string; detail: string }
+ }) => string)
+ | undefined
+ expect(
+ formatter?.({
+ taskId: 'task_1',
+ dispatchId: 'ctx_1',
+ state: 'ready',
+ mode: {
+ mode: 'terminal',
+ preferred: 'structured',
+ reason: 'reused_terminal',
+ detail:
+ 'Your default is a structured chat session, but --terminal reuses a running terminal agent; started a terminal agent worker instead.'
+ }
+ })
+ ).toContain('but --terminal reuses a running terminal agent')
+ })
+
it('prints the retained-process warning for a manual worker-stop', async () => {
callMock.mockResolvedValue({
result: {
diff --git a/src/cli/handlers/orchestration.test.ts b/src/cli/handlers/orchestration.test.ts
index 393bb31d141..d8cf528671d 100644
--- a/src/cli/handlers/orchestration.test.ts
+++ b/src/cli/handlers/orchestration.test.ts
@@ -16,24 +16,6 @@ import { ORCHESTRATION_HANDLERS } from './orchestration'
import { RuntimeClientError } from '../runtime-client'
import { printResult } from '../format'
-function staleHandleError(): RuntimeClientError {
- return new RuntimeClientError('terminal_handle_stale', 'terminal_handle_stale')
-}
-
-// Queues the stale-handle remint chain shared by coordinator commands:
-// stale terminal.show → resolvePane returns liveHandle → downstream RPC result.
-function stubStaleHandleRemint(liveHandle: string, downstream: unknown): void {
- callMock
- .mockRejectedValueOnce(staleHandleError())
- .mockResolvedValueOnce({ result: { terminal: { handle: liveHandle } } })
- .mockResolvedValueOnce(downstream)
-}
-
-// Queues a stale terminal.show followed by a resolvePane remint that fails with `error`.
-function stubStaleHandleRemintFailure(error: RuntimeClientError): void {
- callMock.mockRejectedValueOnce(staleHandleError()).mockRejectedValueOnce(error)
-}
-
afterEach(() => {
getTerminalHandleMock.mockReset()
if (originalTerminalHandle === undefined) {
@@ -332,309 +314,6 @@ describe('orchestration send structured payload flags', () => {
)
})
-describe('orchestration dispatch coordinator handle', () => {
- beforeEach(() => {
- callMock.mockReset()
- getTerminalHandleMock.mockReset()
- delete process.env.ORCA_TERMINAL_HANDLE
- delete process.env.ORCA_PANE_KEY
- })
-
- const invokeDispatch = (flags: Map) =>
- ORCHESTRATION_HANDLERS['orchestration dispatch']({
- flags,
- client: { call: callMock },
- cwd: '/tmp/repo',
- json: true
- } as never)
-
- const invokeDispatchShow = (flags: Map) =>
- ORCHESTRATION_HANDLERS['orchestration dispatch-show']({
- flags,
- client: { call: callMock },
- cwd: '/tmp/repo',
- json: true
- } as never)
-
- const invokeRun = (flags: Map) =>
- ORCHESTRATION_HANDLERS['orchestration coordinator-start']({
- flags,
- client: { call: callMock },
- cwd: '/tmp/repo',
- json: true
- } as never)
-
- it('remints a stale coordinator env handle from the caller pane key', async () => {
- process.env.ORCA_TERMINAL_HANDLE = 'term_stale_coord'
- process.env.ORCA_PANE_KEY = 'tab_coord:leaf_coord'
- stubStaleHandleRemint('term_live_coord', {
- result: { dispatch: { id: 'ctx_1', task_id: 'task_1', status: 'dispatched' } }
- })
- getTerminalHandleMock.mockRejectedValue(new Error('active terminal fallback is unsafe'))
-
- await invokeDispatch(
- new Map([
- ['task', 'task_1'],
- ['to', 'term_worker'],
- ['inject', true]
- ])
- )
-
- expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.show', {
- terminal: 'term_stale_coord'
- })
- expect(callMock).toHaveBeenNthCalledWith(2, 'terminal.resolvePane', {
- paneKey: 'tab_coord:leaf_coord'
- })
- expect(getTerminalHandleMock).not.toHaveBeenCalled()
- expect(callMock).toHaveBeenNthCalledWith(3, 'orchestration.dispatch', {
- task: 'task_1',
- to: 'term_worker',
- from: 'term_live_coord',
- inject: true,
- dryRun: undefined,
- returnPreamble: undefined,
- devMode: false
- })
- })
-
- it('rejects stale coordinator env handles when the caller pane cannot be proven', async () => {
- process.env.ORCA_TERMINAL_HANDLE = 'term_stale_coord'
- callMock.mockRejectedValueOnce(staleHandleError())
- getTerminalHandleMock.mockResolvedValue('term_wrong_active')
-
- await expect(
- invokeDispatch(
- new Map([
- ['task', 'task_1'],
- ['to', 'term_worker']
- ])
- )
- ).rejects.toMatchObject({
- code: 'no_active_sender_terminal'
- })
-
- expect(callMock).toHaveBeenCalledTimes(1)
- expect(getTerminalHandleMock).not.toHaveBeenCalled()
- })
-
- it('propagates unexpected caller pane remint failures for coordinator commands', async () => {
- process.env.ORCA_TERMINAL_HANDLE = 'term_stale_coord'
- process.env.ORCA_PANE_KEY = 'tab_coord:leaf_coord'
- stubStaleHandleRemintFailure(
- new RuntimeClientError('runtime_unavailable', 'runtime_unavailable')
- )
- getTerminalHandleMock.mockResolvedValue('term_wrong_active')
-
- await expect(
- invokeDispatch(
- new Map([
- ['task', 'task_1'],
- ['to', 'term_worker']
- ])
- )
- ).rejects.toMatchObject({
- code: 'runtime_unavailable'
- })
-
- expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.show', {
- terminal: 'term_stale_coord'
- })
- expect(callMock).toHaveBeenNthCalledWith(2, 'terminal.resolvePane', {
- paneKey: 'tab_coord:leaf_coord'
- })
- expect(callMock).toHaveBeenCalledTimes(2)
- expect(getTerminalHandleMock).not.toHaveBeenCalled()
- })
-
- it('uses a live coordinator handle for dispatch-show preamble previews', async () => {
- process.env.ORCA_TERMINAL_HANDLE = 'term_stale_coord'
- process.env.ORCA_PANE_KEY = 'tab_coord:leaf_coord'
- stubStaleHandleRemint('term_live_coord', {
- result: { dispatch: null, preamble: 'preamble' }
- })
- getTerminalHandleMock.mockRejectedValue(new Error('active terminal fallback is unsafe'))
-
- await invokeDispatchShow(
- new Map([
- ['task', 'task_1'],
- ['preamble', true]
- ])
- )
-
- expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.show', {
- terminal: 'term_stale_coord'
- })
- expect(callMock).toHaveBeenNthCalledWith(2, 'terminal.resolvePane', {
- paneKey: 'tab_coord:leaf_coord'
- })
- expect(callMock).toHaveBeenNthCalledWith(3, 'orchestration.dispatchShow', {
- task: 'task_1',
- preamble: true,
- from: 'term_live_coord',
- devMode: false
- })
- })
-
- it('retires the legacy coordinator command without runtime effects', async () => {
- await expect(
- invokeRun(new Map([['spec', 'run the plan']]))
- ).rejects.toMatchObject({
- code: 'orchestration_migration_required',
- data: {
- reason: 'command_retired',
- effectsApplied: false,
- nextCommandArgs: ['skills', 'get', 'orchestration', '--full']
- }
- })
- expect(callMock).not.toHaveBeenCalled()
- })
-})
-
-describe('orchestration task-create caller handle', () => {
- beforeEach(() => {
- callMock.mockReset()
- getTerminalHandleMock.mockReset()
- delete process.env.ORCA_TERMINAL_HANDLE
- delete process.env.ORCA_PANE_KEY
- })
-
- const invokeTaskCreate = (flags: Map) =>
- ORCHESTRATION_HANDLERS['orchestration task-create']({
- flags,
- client: { call: callMock },
- cwd: '/tmp/repo',
- json: true
- } as never)
-
- it('records a live env terminal handle as task creator', async () => {
- process.env.ORCA_TERMINAL_HANDLE = 'term_creator'
- callMock
- .mockResolvedValueOnce({ result: { terminal: { handle: 'term_creator' } } })
- .mockResolvedValueOnce({ result: { task: { id: 'task_1', status: 'ready' } } })
-
- await invokeTaskCreate(new Map([['spec', 'do work']]))
-
- expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.show', { terminal: 'term_creator' })
- expect(callMock).toHaveBeenNthCalledWith(2, 'orchestration.taskCreate', {
- spec: 'do work',
- taskTitle: undefined,
- displayName: undefined,
- deps: undefined,
- parent: undefined,
- run: undefined,
- callerTerminalHandle: 'term_creator'
- })
- })
-
- it('fails closed when a stale task creator handle cannot be reminted', async () => {
- process.env.ORCA_TERMINAL_HANDLE = 'term_stale'
- callMock.mockRejectedValueOnce(staleHandleError())
- getTerminalHandleMock.mockResolvedValue('term_wrong_active')
-
- await expect(
- invokeTaskCreate(new Map([['spec', 'do work']]))
- ).rejects.toMatchObject({ code: 'no_active_sender_terminal' })
-
- expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.show', { terminal: 'term_stale' })
- expect(getTerminalHandleMock).not.toHaveBeenCalled()
- expect(callMock).toHaveBeenCalledTimes(1)
- })
-
- it('propagates runtime unavailability while proving the bound coordinator', async () => {
- process.env.ORCA_TERMINAL_HANDLE = 'term_creator'
- callMock.mockRejectedValueOnce(
- new RuntimeClientError('runtime_unavailable', 'runtime_unavailable')
- )
-
- await expect(
- invokeTaskCreate(new Map([['spec', 'do work']]))
- ).rejects.toMatchObject({ code: 'runtime_unavailable' })
-
- expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.show', { terminal: 'term_creator' })
- expect(getTerminalHandleMock).not.toHaveBeenCalled()
- expect(callMock).toHaveBeenCalledTimes(1)
- })
-
- it('propagates runtime unavailability while reminting the bound coordinator', async () => {
- process.env.ORCA_TERMINAL_HANDLE = 'term_stale'
- process.env.ORCA_PANE_KEY = 'tab_creator:leaf_creator'
- stubStaleHandleRemintFailure(
- new RuntimeClientError('runtime_unavailable', 'runtime_unavailable')
- )
- getTerminalHandleMock.mockResolvedValue('term_wrong_active')
-
- await expect(
- invokeTaskCreate(new Map([['spec', 'do work']]))
- ).rejects.toMatchObject({ code: 'runtime_unavailable' })
-
- expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.show', { terminal: 'term_stale' })
- expect(callMock).toHaveBeenNthCalledWith(2, 'terminal.resolvePane', {
- paneKey: 'tab_creator:leaf_creator'
- })
- expect(getTerminalHandleMock).not.toHaveBeenCalled()
- expect(callMock).toHaveBeenCalledTimes(2)
- })
-
- it('propagates unexpected caller pane remint failures for task creation', async () => {
- process.env.ORCA_TERMINAL_HANDLE = 'term_stale'
- process.env.ORCA_PANE_KEY = 'tab_creator:leaf_creator'
- stubStaleHandleRemintFailure(new RuntimeClientError('permission_denied', 'denied'))
- getTerminalHandleMock.mockResolvedValue('term_wrong_active')
-
- await expect(
- invokeTaskCreate(new Map([['spec', 'do work']]))
- ).rejects.toMatchObject({
- code: 'permission_denied'
- })
-
- expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.show', { terminal: 'term_stale' })
- expect(callMock).toHaveBeenNthCalledWith(2, 'terminal.resolvePane', {
- paneKey: 'tab_creator:leaf_creator'
- })
- expect(callMock).toHaveBeenCalledTimes(2)
- expect(getTerminalHandleMock).not.toHaveBeenCalled()
- })
-
- it('propagates unexpected env handle validation failures', async () => {
- process.env.ORCA_TERMINAL_HANDLE = 'term_creator'
- callMock.mockRejectedValueOnce(new RuntimeClientError('permission_denied', 'denied'))
-
- await expect(
- invokeTaskCreate(new Map([['spec', 'do work']]))
- ).rejects.toMatchObject({
- code: 'permission_denied'
- })
-
- expect(callMock).toHaveBeenCalledTimes(1)
- })
-
- it('remints a stale task creator env handle from the caller pane key', async () => {
- process.env.ORCA_TERMINAL_HANDLE = 'term_stale'
- process.env.ORCA_PANE_KEY = 'tab_creator:leaf_creator'
- stubStaleHandleRemint('term_live', {
- result: { task: { id: 'task_1', status: 'ready' } }
- })
- getTerminalHandleMock.mockRejectedValue(new Error('active terminal fallback is unsafe'))
-
- await invokeTaskCreate(new Map([['spec', 'do work']]))
-
- expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.show', { terminal: 'term_stale' })
- expect(callMock).toHaveBeenNthCalledWith(2, 'terminal.resolvePane', {
- paneKey: 'tab_creator:leaf_creator'
- })
- expect(getTerminalHandleMock).not.toHaveBeenCalled()
- expect(callMock).toHaveBeenNthCalledWith(3, 'orchestration.taskCreate', {
- spec: 'do work',
- taskTitle: undefined,
- displayName: undefined,
- deps: undefined,
- parent: undefined,
- run: undefined,
- callerTerminalHandle: 'term_live'
- })
- })
-})
describe('orchestration timeout flag validation', () => {
const invalidTimeoutValues: [string, string | boolean][] = [
['missing', true],
diff --git a/src/cli/handlers/orchestration/terminal-identity.ts b/src/cli/handlers/orchestration/terminal-identity.ts
index 5da11afac1d..505bfaac262 100644
--- a/src/cli/handlers/orchestration/terminal-identity.ts
+++ b/src/cli/handlers/orchestration/terminal-identity.ts
@@ -2,6 +2,7 @@ import type { RuntimeClient } from '../../runtime-client'
import { getOptionalStringFlag } from '../../flags'
import { RuntimeClientError } from '../../runtime-client'
import { getTerminalHandle } from '../../selectors'
+import { isStructuredSessionWithoutIdentity } from '../../../shared/structured-session-marker'
export async function resolveOrchestrationTerminalHandle(
flags: Map,
@@ -29,13 +30,56 @@ export async function resolveOrchestrationTerminalHandle(
}
return envHandle
}
+ // Past this point every remaining route GUESSES an implicit terminal, and a structured session
+ // has no pane for the guess to land on — so it lands on a sibling. `check` is destructive by
+ // default, so that guess consumed another pane's oldest unread batch and marked it read, and the
+ // rightful worker never saw its mail. Refusing is the only honest answer: this child genuinely
+ // cannot infer its own identity.
+ if (isStructuredSessionWithoutIdentity()) {
+ throw new RuntimeClientError(
+ 'no_active_sender_terminal',
+ `This chat session has no orchestration identity of its own, so --${flagName} cannot be inferred. ` +
+ `Pass --${flagName} explicitly; guessing would act on another pane's mailbox.`
+ )
+ }
if (flagName === 'from') {
return await resolveImplicitOrchestrationSender(flags, cwd, client)
}
return await getTerminalHandle(flags, cwd, client)
}
+/**
+ * Whether the handle this process was born with still names a live identity.
+ *
+ * `terminal.resolveIdentity`, never `terminal.show`: `show` is a PTY verb, so it missed for a
+ * structured worker and reported `terminal_handle_stale` for a handle that was perfectly live —
+ * which then failed every coordinator verb, because the pane remint below needs an `ORCA_PANE_KEY`
+ * a structured child deliberately does not carry.
+ */
async function isLiveTerminalHandle(handle: string, client: RuntimeClient): Promise {
+ try {
+ const response = await client.call<{ identity?: { live?: boolean } }>(
+ 'terminal.resolveIdentity',
+ { terminal: handle }
+ )
+ const live = response.result?.identity?.live
+ // An unrecognised shape is an older host answering something else, not a dead handle.
+ return typeof live === 'boolean' ? live : await showResolvesTerminalHandle(handle, client)
+ } catch (err) {
+ if (isStaleTerminalIdentityError(err)) {
+ return false
+ }
+ if (getClientErrorCode(err) === 'method_not_found') {
+ // Clients and remote hosts update independently, so a host that predates the identity probe
+ // is the normal mixed-version state. Fall back to what it does have — which is correct for
+ // that host, because a host without the probe also has no structured workers to miss.
+ return await showResolvesTerminalHandle(handle, client)
+ }
+ throw err
+ }
+}
+
+async function showResolvesTerminalHandle(handle: string, client: RuntimeClient): Promise {
try {
await client.call('terminal.show', { terminal: handle })
return true
@@ -133,7 +177,9 @@ async function resolveImplicitOrchestrationSender(
client: RuntimeClient
): Promise {
try {
- return await getTerminalHandle(flags, cwd, client)
+ // Unambiguous: naming the sender is an identity claim, so an arbitrary pick would let this
+ // command speak as a sibling worker.
+ return await getTerminalHandle(flags, cwd, client, { requireUnambiguous: true })
} catch (err) {
if (!isNoActiveTerminalError(err)) {
throw err
diff --git a/src/cli/handlers/orchestration/worker-launch-handler.ts b/src/cli/handlers/orchestration/worker-launch-handler.ts
index 97517373a1c..b6e4d92799c 100644
--- a/src/cli/handlers/orchestration/worker-launch-handler.ts
+++ b/src/cli/handlers/orchestration/worker-launch-handler.ts
@@ -41,6 +41,7 @@ export const ORCHESTRATION_WORKER_LAUNCH_HANDLER: Record
failedStage?: string
lastError?: string
warning?: string
+ mode?: { mode: string; preferred: string; reason: string; detail: string }
effects: unknown[]
residualResources: unknown[]
nextCommands?: string[]
diff --git a/src/cli/handlers/orchestration/worker-output.ts b/src/cli/handlers/orchestration/worker-output.ts
index fb9d7018e46..9d6d1949911 100644
--- a/src/cli/handlers/orchestration/worker-output.ts
+++ b/src/cli/handlers/orchestration/worker-output.ts
@@ -1,10 +1,6 @@
-import {
- isSubagentGroupFallbackText,
- subagentGroupFallbackText
-} from '../../../shared/native-chat-subagent-summary'
-import type { NativeChatMessage } from '../../../shared/native-chat-types'
import type { RuntimeTerminalRead } from '../../../shared/runtime-types'
import type { OrchestrationWorkerReadResult } from '../../../shared/orchestration-worker-output'
+import { formatWorkerTranscriptMessage } from '../../../shared/worker-transcript-text'
export type LegacyWorkerReadResult = {
dispatchId: string
@@ -12,6 +8,7 @@ export type LegacyWorkerReadResult = {
}
export type WorkerStartReceipt = {
+ mode?: { detail: string }
taskId: string
dispatchId: string
state: string
@@ -25,6 +22,11 @@ export type WorkerStartReceipt = {
export function formatWorkerStart(value: WorkerStartReceipt): string {
const lines = [`Worker ${value.dispatchId} [${value.state}] for ${value.taskId}`]
+ // Settings-driven rather than requested, so the human line always names the mode that ran: a
+ // fallback from the user's structured default is never silent.
+ if (value.mode) {
+ lines.push(value.mode.detail)
+ }
if (value.lastError) {
lines.push(`${value.failedStage ?? 'start'}: ${value.lastError}`)
} else if (value.warning) {
@@ -105,44 +107,6 @@ function formatWorkerReadDetails(value: OrchestrationWorkerReadResult): string {
return lines.join('\n')
}
-function formatWorkerTranscriptMessage(message: NativeChatMessage): string {
- // Every roster block is written beside a plain-text twin carrying the same
- // sentence, for clients that cannot draw the block. This CLI is one, so it
- // prints the twin and drops the block — the mirror of the renderer, which
- // draws the block and drops the twin. Either way the sentence prints once.
- const hasTwin = message.blocks.some(
- (block) => block.type === 'text' && isSubagentGroupFallbackText(block.text)
- )
- const blocks = message.blocks.flatMap((block): string[] => {
- if (block.type === 'text') {
- return [block.text]
- }
- if (block.type === 'tool-call') {
- return [`[tool ${block.name}] ${safeJson(block.input)}`]
- }
- if (block.type === 'tool-result') {
- return [`[tool result${block.isError ? ' error' : ''}] ${block.output}`]
- }
- if (block.type === 'image-ref') {
- return [block.url ? `[image] ${block.url}` : `[image omitted]`]
- }
- if (block.type === 'subagent-group') {
- // Stand in for the block only when no twin is being printed: the wire
- // admits a roster that arrived without one, and dropping that
- // unconditionally would lose the sentence altogether. Presence, not a
- // byte compare against a recomputed sentence — a roster from a newer
- // build holds a state this build reads as `unverifiable`, so recomputing
- // yields a different sentence and both would print.
- return hasTwin ? [] : [`[subagents] ${subagentGroupFallbackText(block.agents)}`]
- }
- // The journal deliberately admits block types this build does not know, and
- // a newer remote host can send one over the wire. Degrade to a marker rather
- // than reading fields off a shape that has none.
- return ['[unsupported block]']
- })
- return `[${message.role}] ${blocks.join('\n')}`.trimEnd()
-}
-
export type WorkerReleaseReceipt = {
dispatchId: string
state: string
@@ -169,11 +133,3 @@ export function formatWorkerRelease(value: WorkerReleaseReceipt): string {
}
return lines.join('\n')
}
-
-function safeJson(value: unknown): string {
- try {
- return JSON.stringify(value)
- } catch {
- return '[unserializable input]'
- }
-}
diff --git a/src/cli/orchestration-structured-sender-identity.test.ts b/src/cli/orchestration-structured-sender-identity.test.ts
new file mode 100644
index 00000000000..9b85125d99e
--- /dev/null
+++ b/src/cli/orchestration-structured-sender-identity.test.ts
@@ -0,0 +1,151 @@
+/**
+ * The env-handle path, with NO `--from`.
+ *
+ * Every other orchestration CLI test passes `--from term_coord` explicitly, so the resolver a real
+ * worker actually goes through — `ORCA_TERMINAL_HANDLE` plus `validateEnvHandle` — was never
+ * exercised. That is why twelve coordinator verbs could fail for a structured worker while the
+ * whole suite stayed green, and why the worker's own preamble (which tells it to run these with no
+ * `--from`) failed on its first line.
+ */
+
+import { describe, expect, it, vi } from 'vitest'
+
+const {
+ callMock,
+ runtimeClientConstructorMock,
+ serveOrcaAppMock,
+ getDefaultUserDataPathMock,
+ addEnvironmentFromPairingCodeMock,
+ listEnvironmentsMock,
+ spawnMock
+} = vi.hoisted(() => ({
+ callMock: vi.fn(),
+ runtimeClientConstructorMock: vi.fn(),
+ serveOrcaAppMock: vi.fn(),
+ getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
+ addEnvironmentFromPairingCodeMock: vi.fn(),
+ listEnvironmentsMock: vi.fn(),
+ spawnMock: vi.fn()
+}))
+
+vi.mock('./runtime-client', async () => {
+ const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
+ return createRuntimeClientModuleMock({
+ callMock,
+ runtimeClientConstructorMock,
+ serveOrcaAppMock,
+ getDefaultUserDataPathMock
+ })
+})
+
+vi.mock('./runtime/environments', () => ({
+ addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
+ listEnvironments: listEnvironmentsMock,
+ removeEnvironment: vi.fn(),
+ resolveEnvironment: vi.fn()
+}))
+
+vi.mock('child_process', async () => {
+ const { createChildProcessModuleMock } = await import('./index-test-harness.js')
+ return createChildProcessModuleMock(spawnMock)
+})
+
+import { main } from './index'
+import { useWorktreeAwarenessEnvironment } from './index-test-harness'
+
+const STRUCTURED_HANDLE = 'structworker_a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d'
+
+/**
+ * Every command below is one of the twelve that resolve their sender through
+ * `resolveCoordinatorTerminalHandle`. All twelve share ONE resolver and one liveness probe, so the
+ * set is sufficient if it covers the distinct shapes that reach it: a read, a mutation, a
+ * run-scoped verb, a dispatch verb and a worker-start. A per-verb sweep would pin the argv parsing
+ * of twelve handlers and still tell us nothing more about the seam that actually broke.
+ */
+const SENDER_VERBS: { argv: string[]; method: string }[] = [
+ { argv: ['orchestration', 'run-current'], method: 'orchestration.runCurrent' },
+ { argv: ['orchestration', 'run-create', '--objective', 'x'], method: 'orchestration.runCreate' },
+ { argv: ['orchestration', 'task-list'], method: 'orchestration.taskList' },
+ { argv: ['orchestration', 'gate-list'], method: 'orchestration.gateList' },
+ { argv: ['orchestration', 'dispatch-show', '--task', 't1'], method: 'orchestration.dispatchShow' }
+]
+
+describe('a structured worker running orchestration commands as itself', () => {
+ useWorktreeAwarenessEnvironment({
+ callMock,
+ serveOrcaAppMock,
+ getDefaultUserDataPathMock,
+ addEnvironmentFromPairingCodeMock,
+ listEnvironmentsMock,
+ spawnMock
+ })
+
+ function answerCalls(): void {
+ callMock.mockImplementation(async (method: string) => {
+ if (method === 'terminal.resolveIdentity') {
+ return {
+ id: 'req',
+ ok: true,
+ result: { identity: { handle: STRUCTURED_HANDLE, live: true } },
+ _meta: { runtimeId: 'runtime-1' }
+ }
+ }
+ return { id: 'req', ok: true, result: {}, _meta: { runtimeId: 'runtime-1' } }
+ })
+ }
+
+ it.each(SENDER_VERBS)(
+ 'resolves its own identity for $method with no --from',
+ async ({ argv, method }) => {
+ // The defect this pins: the sender resolver validated the env handle with `terminal.show`, a
+ // PTY verb that misses for a structured worker and answers `terminal_handle_stale`. The pane
+ // remint that would have recovered it needs `ORCA_PANE_KEY`, which a structured child
+ // deliberately does not carry, so the command died on `no_active_sender_terminal`.
+ process.env.ORCA_TERMINAL_HANDLE = STRUCTURED_HANDLE
+ answerCalls()
+ vi.spyOn(console, 'log').mockImplementation(() => {})
+ await expect(main(argv)).resolves.not.toThrow()
+ const called = callMock.mock.calls.map((call) => call[0] as string)
+ expect(called).toContain(method)
+ // Never through `terminal.show`: teaching that verb structured handles would hand every
+ // public terminal verb something that looks writable and is not.
+ expect(called).not.toContain('terminal.show')
+ }
+ )
+
+ it('sends the structured handle as the sender, not a guessed sibling', async () => {
+ process.env.ORCA_TERMINAL_HANDLE = STRUCTURED_HANDLE
+ answerCalls()
+ vi.spyOn(console, 'log').mockImplementation(() => {})
+ await main(['orchestration', 'run-create', '--objective', 'x'])
+ const create = callMock.mock.calls.find((call) => call[0] === 'orchestration.runCreate')
+ expect((create?.[1] as { from?: string } | undefined)?.from).toBe(STRUCTURED_HANDLE)
+ })
+
+ it('still refuses a handle the runtime reports dead, with no pane key to remint from', async () => {
+ // The invariant the fix must not break: a stale `ORCA_TERMINAL_HANDLE` in a long-lived shell
+ // must keep failing rather than being baked into a coordinator preamble.
+ process.env.ORCA_TERMINAL_HANDLE = 'term_stale'
+ callMock.mockImplementation(async (method: string) => {
+ if (method === 'terminal.resolveIdentity') {
+ return {
+ id: 'req',
+ ok: true,
+ result: { identity: { handle: 'term_stale', live: false } },
+ _meta: { runtimeId: 'runtime-1' }
+ }
+ }
+ return { id: 'req', ok: true, result: {}, _meta: { runtimeId: 'runtime-1' } }
+ })
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const priorExitCode = process.exitCode
+ await main(['orchestration', 'run-create', '--objective', 'x'])
+ expect(process.exitCode).toBe(1)
+ expect(errorSpy.mock.calls.flat().join(' ')).toMatch(
+ /no_active_sender_terminal|sender terminal/i
+ )
+ expect(callMock.mock.calls.map((call) => call[0])).not.toContain('orchestration.runCreate')
+ process.exitCode = priorExitCode
+ errorSpy.mockRestore()
+ })
+})
diff --git a/src/cli/orchestration-structured-session-no-identity.test.ts b/src/cli/orchestration-structured-session-no-identity.test.ts
new file mode 100644
index 00000000000..d7c76f2b5e0
--- /dev/null
+++ b/src/cli/orchestration-structured-session-no-identity.test.ts
@@ -0,0 +1,98 @@
+/**
+ * A structured chat session with NO orchestration identity must refuse, not guess.
+ *
+ * Non-worker structured sessions get no `ORCA_TERMINAL_HANDLE`, so `orchestration check` fell
+ * through to the active-terminal guess — and `check` is destructive by default, so it consumed
+ * another pane's oldest unread batch and marked it read. The rightful worker never saw that mail.
+ *
+ * The case pinned here is ONE terminal pane in the worktree, because that is the case
+ * `requireUnambiguous` misses: with a single candidate the guess still resolves, to a sibling.
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { ORCA_STRUCTURED_SESSION_ENV } from '../shared/structured-session-marker'
+
+const callMock = vi.hoisted(() => vi.fn())
+const getTerminalHandleMock = vi.hoisted(() => vi.fn())
+
+vi.mock('./format', () => ({ printResult: vi.fn() }))
+vi.mock('./selectors', () => ({ getTerminalHandle: getTerminalHandleMock }))
+
+import { ORCHESTRATION_HANDLERS } from './handlers/orchestration'
+
+const originalMarker = process.env[ORCA_STRUCTURED_SESSION_ENV]
+const originalHandle = process.env.ORCA_TERMINAL_HANDLE
+
+function invoke(command: string, flags = new Map()) {
+ return ORCHESTRATION_HANDLERS[command]!({
+ flags,
+ client: { call: callMock },
+ cwd: '/tmp/repo',
+ json: true
+ } as never)
+}
+
+describe('a structured chat session with no orchestration identity', () => {
+ beforeEach(() => {
+ callMock.mockReset()
+ getTerminalHandleMock.mockReset()
+ delete process.env.ORCA_TERMINAL_HANDLE
+ process.env[ORCA_STRUCTURED_SESSION_ENV] = '1'
+ // Exactly ONE terminal pane in the worktree: the single-candidate case, where
+ // `requireUnambiguous` still resolves and would hand this session a sibling's handle.
+ getTerminalHandleMock.mockResolvedValue('term_sibling')
+ })
+
+ afterEach(() => {
+ if (originalMarker === undefined) {
+ delete process.env[ORCA_STRUCTURED_SESSION_ENV]
+ } else {
+ process.env[ORCA_STRUCTURED_SESSION_ENV] = originalMarker
+ }
+ if (originalHandle === undefined) {
+ delete process.env.ORCA_TERMINAL_HANDLE
+ } else {
+ process.env.ORCA_TERMINAL_HANDLE = originalHandle
+ }
+ })
+
+ it('refuses a bare check instead of consuming the mailbox of a sibling pane', async () => {
+ await expect(invoke('orchestration check')).rejects.toMatchObject({
+ code: 'no_active_sender_terminal',
+ message: expect.stringContaining('--terminal')
+ })
+ // Neither guessed nor sent: a destructive read must not reach the runtime at all.
+ expect(getTerminalHandleMock).not.toHaveBeenCalled()
+ expect(callMock).not.toHaveBeenCalled()
+ })
+
+ it('refuses a bare send for the same reason, naming --from', async () => {
+ await expect(
+ invoke(
+ 'orchestration send',
+ new Map([
+ ['to', 'term_coord'],
+ ['subject', 'hi'],
+ ['body', 'hello']
+ ])
+ )
+ ).rejects.toMatchObject({ message: expect.stringContaining('--from') })
+ expect(callMock).not.toHaveBeenCalled()
+ })
+
+ it('still accepts an explicit --terminal, which is the actionable escape', async () => {
+ callMock.mockResolvedValue({ result: { messages: [], count: 0 } })
+ await invoke('orchestration check', new Map([['terminal', 'structworker_self']]))
+ expect(callMock).toHaveBeenCalledWith(
+ 'orchestration.check',
+ expect.objectContaining({ terminal: 'structworker_self' })
+ )
+ })
+
+ it('leaves an ordinary shell alone, which has no marker and may still guess', async () => {
+ delete process.env[ORCA_STRUCTURED_SESSION_ENV]
+ callMock.mockResolvedValue({ result: { messages: [], count: 0 } })
+ await invoke('orchestration check')
+ expect(getTerminalHandleMock).toHaveBeenCalled()
+ })
+})
diff --git a/src/cli/selectors.ts b/src/cli/selectors.ts
index 0f90dc94508..0db6ef82ff4 100644
--- a/src/cli/selectors.ts
+++ b/src/cli/selectors.ts
@@ -206,14 +206,18 @@ export async function getBrowserWorktreeSelector(
export async function getTerminalHandle(
flags: Map,
cwd: string,
- client: RuntimeClient
+ client: RuntimeClient,
+ options: { requireUnambiguous?: boolean } = {}
): Promise {
const explicit = getOptionalStringFlag(flags, 'terminal')
if (explicit) {
return explicit
}
const worktree = await getBrowserWorktreeSelector(flags, cwd, client)
- const response = await client.call<{ handle: string }>('terminal.resolveActive', { worktree })
+ const response = await client.call<{ handle: string }>('terminal.resolveActive', {
+ worktree,
+ ...(options.requireUnambiguous ? { requireUnambiguous: true } : {})
+ })
return response.result.handle
}
diff --git a/src/cli/specs/orchestration-worker-specs.ts b/src/cli/specs/orchestration-worker-specs.ts
index e11ec3b1a91..c6a54ff1e1a 100644
--- a/src/cli/specs/orchestration-worker-specs.ts
+++ b/src/cli/specs/orchestration-worker-specs.ts
@@ -37,6 +37,9 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [
'--model supports Claude, Codex, and Cursor opaque provider model ids; --effort requires --model. Neither can combine with --terminal.',
'New worktrees use agent-first creation and default --setup to run. Repository start-immediately runs setup beside the agent; wait-for-setup gates agent readiness and task input.',
'Creation flags (--name, --repo, --base-branch, --display-name, --comment, --setup) are rejected for current/existing worktrees. Use exact --repo on the selected server; project/host convenience routing remains on worktree create.',
+ "How the worker runs follows the user's own setting for new agent tabs; there is no flag for it and no caller needs to ask. A dispatch the setting cannot apply to still starts, so the placement, agent, and launch options passed here are always the ones honoured.",
+ 'Drive every worker the same way whichever way it was started: the same orchestration verbs, the same handle. Mail, dispatch, worker-show, worker-read and the whole lifecycle behave identically. The start receipt records which one ran, for operators and telemetry.',
+ 'Not every worker has a terminal. Read output with worker-read --source auto or --source transcript, which always work; --source terminal is refused when there is none, and orca terminal verbs do not accept every worker handle. Nothing above needs you to know which kind you have — the orchestration verbs cover all of them.',
'--on selects only the worker server; the Run and this command remain on the current Orca server.',
'Remote current and new-child are invalid; discover an exact remote selector or use new-top-level.',
'--retry-of needs --task naming the failed Task (--spec creates a new one) and does not inherit placement; repeat the intended --on/worktree and --agent/terminal choices.',
diff --git a/src/cli/specs/orchestration.test.ts b/src/cli/specs/orchestration.test.ts
index 54df971ba52..cd69aa50708 100644
--- a/src/cli/specs/orchestration.test.ts
+++ b/src/cli/specs/orchestration.test.ts
@@ -16,6 +16,46 @@ describe('orchestration send command spec', () => {
})
})
+describe('orchestration worker-start command spec', () => {
+ const startSpec = ORCHESTRATION_COMMAND_SPECS.find(
+ (spec) => spec.path.join(' ') === 'orchestration worker-start'
+ )
+
+ it('offers no flag for the worker mode, because settings decide it', () => {
+ expect(startSpec?.allowedFlags).not.toContain('structured')
+ expect(startSpec?.usage).not.toContain('--structured')
+ expect(startSpec?.notes?.join('\n')).not.toContain('--structured')
+ })
+
+ it('documents the settings default and the fallback that keeps every dispatch working', () => {
+ const notes = startSpec?.notes?.join('\n') ?? ''
+ expect(notes).toContain("follows the user's own setting for new agent tabs")
+ expect(notes).toContain('A dispatch the setting cannot apply to still starts')
+ })
+
+ it('never points a caller at the worker kind, which nothing it runs depends on', () => {
+ const notes = startSpec?.notes?.join('\n') ?? ''
+ // The mode is in the receipt for operators and telemetry. Naming the field here would teach a
+ // coordinator agent to branch on something no verb it runs behaves differently for.
+ expect(notes).not.toMatch(/mode field/)
+ expect(notes).not.toMatch(/structured chat session/)
+ expect(notes).toContain('Drive every worker the same way')
+ })
+
+ it('does not promise uniformity it cannot deliver', () => {
+ // The note used to promise "the same verbs, the same handle, and the same worker-read
+ // sources". All three clauses were false for a worker with no terminal: `orca terminal` verbs
+ // refuse its handle and `--source terminal` has nothing to serve. A spec agents read must not
+ // carry a false promise — but it also must not name the worker kind, or a coordinator starts
+ // branching on something no verb it runs behaves differently for. So it states the limitation
+ // and the always-working alternative, without naming a mode.
+ const notes = startSpec?.notes?.join('\n') ?? ''
+ expect(notes).not.toContain('the same worker-read sources')
+ expect(notes).toContain('Not every worker has a terminal')
+ expect(notes).toContain('--source transcript')
+ })
+})
+
describe('orchestration check command spec', () => {
it('documents --types as a wake condition rather than a batch filter', () => {
const checkSpec = ORCHESTRATION_COMMAND_SPECS.find(
diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts
index cb1107a8622..93d15e5a2e6 100644
--- a/src/main/agent-hooks/first-work-branch-rename.test.ts
+++ b/src/main/agent-hooks/first-work-branch-rename.test.ts
@@ -1,6 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { Repo } from '../../shared/repo-types'
+import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types'
+import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store'
+import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed'
+import { maybeAutoRenameWorkspaceOnFirstStructuredTurn } from './first-work-structured-session-rename'
import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree/id'
const {
@@ -83,6 +87,134 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
)
})
+ it.each([
+ ['claude', WORKTREE_ID],
+ ['codex', WORKTREE_ID],
+ ['claude', FOLDER_WORKTREE_ID],
+ ['codex', FOLDER_WORKTREE_ID]
+ ] as const)(
+ 'renames %s workspace %s on live work without a subscriber, preserving replay, dedupe and retries',
+ async (agent, workspaceId) => {
+ const { deps, setDisplayName } = makeDeps({
+ getFolderWorkspacePath: () => '/workspace/platform',
+ isPendingFirstAgentMessageRename: () => true
+ })
+ const items: AgentJournalRenderItem[] = []
+ const journal = {
+ lastActivityAt: () => 0,
+ snapshot: () => ({ items }),
+ lastActivityAt: () => 1,
+ isReadOnly: false
+ } as unknown as AgentSessionJournal
+ const pending: Promise[] = []
+ const observe = vi.fn((summary, options) => {
+ const work = maybeAutoRenameWorkspaceOnFirstStructuredTurn(summary, options, deps)
+ if (work) {
+ pending.push(work)
+ }
+ })
+ const feed = new StructuredAgentSessionStatusFeed({
+ sessions: new Map([
+ ['session', { journal, params: { location: { workspaceId }, provider: agent } }]
+ ]),
+ getRecord: () => null,
+ now: () => 1,
+ onStatusChanged: observe
+ })
+ const user = {
+ body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Fix auth' }] }
+ } as AgentJournalRenderItem
+ const turn = {
+ body: {
+ kind: 'status',
+ text: 'Working',
+ turnLifecycle: { turnId: 'turn-1', state: 'running' }
+ }
+ } as AgentJournalRenderItem
+ items.push(user, turn)
+ feed.publish('session', journal, { replay: true })
+ await Promise.all(pending)
+ expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
+
+ items.pop()
+ feed.publish('session', journal)
+ items.push(turn)
+ generateBranchNameMock.mockResolvedValueOnce({ success: false, error: 'temporary failure' })
+ feed.publish('session', journal)
+ await Promise.all(pending)
+ expect(generateBranchNameMock).toHaveBeenCalledOnce()
+ expect(setDisplayName).not.toHaveBeenCalled()
+ const callsBeforeOutput = observe.mock.calls.length
+ for (let index = 0; index < 100; index++) {
+ feed.publish('session', journal)
+ }
+ expect(observe).toHaveBeenCalledTimes(callsBeforeOutput)
+
+ items.pop()
+ feed.publish('session', journal)
+ items.push(turn)
+ feed.publish('session', journal)
+ await Promise.all(pending)
+ expect(generateBranchNameMock).toHaveBeenCalledTimes(2)
+ expect(setDisplayName).toHaveBeenCalledWith(workspaceId, 'Fix auth')
+ if (workspaceId === FOLDER_WORKTREE_ID) {
+ expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
+ } else {
+ expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
+ ['branch', '-m', 'you/fix-auth'],
+ expect.anything()
+ )
+ }
+ }
+ )
+
+ it('does not probe git for a folder-project structured session with a synthetic worktree id', async () => {
+ const workspaceId = `${REPO_ID}::/workspace/platform::workspace:123e4567-e89b-12d3-a456-426614174000`
+ const { deps, setDisplayName, setRenameError } = makeDeps({
+ getRepo: () => ({ id: REPO_ID, kind: 'folder', path: '/workspace/platform' }) as Repo
+ })
+ const journal = {
+ lastActivityAt: () => 0,
+ isReadOnly: false,
+ lastActivityAt: () => 1,
+ snapshot: () => ({
+ items: [
+ { body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Fix auth' }] } },
+ {
+ body: {
+ kind: 'status',
+ text: 'Working',
+ turnLifecycle: { turnId: 'turn-1', state: 'running' }
+ }
+ }
+ ]
+ })
+ } as unknown as AgentSessionJournal
+ const location = { workspaceId, workspaceKind: 'git-worktree' as const }
+ const pending: Promise[] = []
+ const feed = new StructuredAgentSessionStatusFeed({
+ sessions: new Map([['session', { journal, params: { location, provider: 'codex' } }]]),
+ getRecord: () => null,
+ now: () => 1,
+ onStatusChanged: (summary, options) => {
+ expect(summary.workspaceId).toBe(workspaceId)
+ const work = maybeAutoRenameWorkspaceOnFirstStructuredTurn(summary, options, deps)
+ if (work) {
+ pending.push(work)
+ }
+ }
+ })
+
+ feed.publish('session', journal)
+ await Promise.all(pending)
+
+ expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
+ expect(getSshGitProviderMock).not.toHaveBeenCalled()
+ expect(generateBranchNameMock).not.toHaveBeenCalled()
+ expect(setDisplayName).not.toHaveBeenCalled()
+ expect(setRenameError).toHaveBeenCalledWith(workspaceId, null)
+ })
+
it('keeps incidental work-item markers from overriding the generated display name', async () => {
const { deps, onRenamed, setDisplayName } = makeDeps()
await maybeAutoRenameBranchOnFirstWork(workingEvent({ prompt: 'Fix auth from note #1' }), deps)
@@ -233,6 +365,30 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
expect(onRenamed).toHaveBeenCalledWith(FOLDER_WORKTREE_ID)
})
+ it.each([true, false])(
+ 'preserves a manual folder name during generation (pending=%s)',
+ async (pendingAfterRename) => {
+ let name = 'Platform workspace'
+ let pending = true
+ const { deps, setDisplayName } = makeDeps({
+ resolveWorktreeIdForTab: () => FOLDER_WORKTREE_ID,
+ getFolderWorkspacePath: () => '/workspace/platform',
+ isPendingFirstAgentMessageRename: () => pending,
+ getCurrentDisplayName: () => name
+ })
+ generateBranchNameMock.mockImplementationOnce(async () => {
+ name = 'My manual title'
+ pending = pendingAfterRename
+ return { success: true, slug: 'fix-auth' }
+ })
+
+ await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
+
+ expect(generateBranchNameMock).toHaveBeenCalledOnce()
+ expect(setDisplayName).not.toHaveBeenCalled()
+ }
+ )
+
it('does not rename folder workspace titles without the pending marker', async () => {
const { deps, setDisplayName } = makeDeps({
resolveWorktreeIdForTab: () => FOLDER_WORKTREE_ID,
diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts
index 45f78e75e8a..355a0a35dc9 100644
--- a/src/main/agent-hooks/first-work-branch-rename.ts
+++ b/src/main/agent-hooks/first-work-branch-rename.ts
@@ -1,6 +1,7 @@
// On first agent work in a fresh workspace, replace the auto-generated creature branch (e.g. `you/Nautilus`) with a short work-derived name.
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { Repo } from '../../shared/repo-types'
+import { isFolderRepo } from '../../shared/repo-kind'
import { getRepoIdFromWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree/id'
import { parseWorkspaceKey } from '../../shared/workspace-scope'
import { parsePaneKey } from '../../shared/stable-pane-id'
@@ -170,6 +171,9 @@ async function runAutoRename(
if (!repo || !parsed) {
return stop('unresolved repo or worktree id')
}
+ if (isFolderRepo(repo)) {
+ return stop('folder project has no branch to rename', true)
+ }
const worktreePath = parsed.worktreePath
const provider = repo.connectionId ? (getSshGitProvider(repo.connectionId) ?? null) : null
diff --git a/src/main/agent-hooks/first-work-rename-runtime.ts b/src/main/agent-hooks/first-work-rename-runtime.ts
new file mode 100644
index 00000000000..ebc9484071b
--- /dev/null
+++ b/src/main/agent-hooks/first-work-rename-runtime.ts
@@ -0,0 +1,119 @@
+import { existsSync } from 'node:fs'
+import { parseWorkspaceKey } from '../../shared/workspace-scope'
+import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'
+import type { FirstWorkBranchRenameDeps } from './first-work-branch-rename'
+import { rememberBranchRenameFailureOutput } from './branch-rename-failure-output'
+import { renameWorktreeFolderOnFirstWork } from './first-work-folder-rename'
+import { moveWorktree } from '../git/worktree'
+import type { Store } from '../persistence'
+import type { OrcaRuntimeService } from '../runtime/orca-runtime'
+
+const ENABLE_FIRST_WORK_FOLDER_RENAME = false
+
+export function firstWorkRenameDeps(
+ store: Store,
+ runtime: Pick<
+ OrcaRuntimeService,
+ | 'getCommitMessageAgentEnvironmentResolvers'
+ | 'notifyFolderWorkspaceChanged'
+ | 'notifyBranchRenamed'
+ | 'notifyWorktreeFolderRenamed'
+ >
+): FirstWorkBranchRenameDeps {
+ return {
+ getSettings: () => store.getSettings(),
+ getRepo: (repoId) => store.getRepo(repoId),
+ getAgentEnvResolvers: () => runtime.getCommitMessageAgentEnvironmentResolvers(),
+ getCurrentDisplayName: (worktreeId) => {
+ const scope = parseWorkspaceKey(worktreeId)
+ return scope?.type === 'folder'
+ ? store.getFolderWorkspace(scope.folderWorkspaceId)?.name
+ : store.getWorktreeMeta(worktreeId)?.displayName
+ },
+ getFolderWorkspacePath: (worktreeId) => {
+ const scope = parseWorkspaceKey(worktreeId)
+ return scope?.type === 'folder'
+ ? store.getFolderWorkspace(scope.folderWorkspaceId)?.folderPath
+ : undefined
+ },
+ isPendingFirstAgentMessageRename: (worktreeId) => {
+ const scope = parseWorkspaceKey(worktreeId)
+ return scope?.type === 'folder'
+ ? store.getFolderWorkspace(scope.folderWorkspaceId)?.pendingFirstAgentMessageRename === true
+ : store.getWorktreeMeta(worktreeId)?.pendingFirstAgentMessageRename === true
+ },
+ canRenameOrcaCreatedBranch: (worktreeId) => {
+ const meta = store.getWorktreeMeta(worktreeId)
+ // Why: a user branch could coincidentally match a creature name; only Orca-stamped worktrees are safe to auto-rename.
+ return !!meta?.orcaCreationSource && meta.preserveBranchOnDelete !== true
+ },
+ setDisplayName: (worktreeId, displayName) => {
+ rememberBranchRenameFailureOutput(worktreeId, null)
+ const scope = parseWorkspaceKey(worktreeId)
+ if (scope?.type === 'folder') {
+ store.updateFolderWorkspace(scope.folderWorkspaceId, {
+ name: displayName,
+ pendingFirstAgentMessageRename: false,
+ firstAgentMessageRenameError: null
+ })
+ runtime.notifyFolderWorkspaceChanged()
+ return
+ }
+ store.setWorktreeMeta(worktreeId, {
+ displayName,
+ // The first-agent title is an intentional user-facing label; keep it stable after the
+ // generated branch is renamed and across subsequent catalog refreshes.
+ displayNameIsPinned: true,
+ pendingFirstAgentMessageRename: false,
+ // Success clears the failure badge (redundant with the explicit setRenameError(null)).
+ firstAgentMessageRenameError: null
+ })
+ },
+ renameWorktreeFolder: ENABLE_FIRST_WORK_FOLDER_RENAME
+ ? (worktreeId, newLeaf) =>
+ renameWorktreeFolderOnFirstWork(worktreeId, newLeaf, {
+ getRepo: (repoId) => store.getRepo(repoId),
+ getSettings: () => store.getSettings(),
+ migrateWorktreeIdentity: (oldId, newId) => store.migrateWorktreeIdentity(oldId, newId),
+ notifyWorktreeRenamed: (repoId, oldId, newId) =>
+ runtime.notifyWorktreeFolderRenamed(repoId, oldId, newId),
+ pathExists: async (candidate) => existsSync(candidate),
+ moveWorktree
+ })
+ : undefined,
+ setRenameError: (worktreeId, error, failureOutput) => {
+ // Refresh the full-output capture before the dedupe below — a repeat error string is still a fresh run.
+ rememberBranchRenameFailureOutput(worktreeId, error === null ? null : failureOutput)
+ // Skip the write + push when unchanged — most settled worktrees never had an error to clear.
+ const scope = parseWorkspaceKey(worktreeId)
+ if (scope?.type === 'folder') {
+ const current = store.getFolderWorkspace(
+ scope.folderWorkspaceId
+ )?.firstAgentMessageRenameError
+ if ((current ?? null) === (error ?? null)) {
+ return
+ }
+ store.updateFolderWorkspace(scope.folderWorkspaceId, {
+ firstAgentMessageRenameError: error
+ })
+ runtime.notifyFolderWorkspaceChanged()
+ return
+ }
+ const current = store.getWorktreeMeta(worktreeId)?.firstAgentMessageRenameError
+ if ((current ?? null) === (error ?? null)) {
+ return
+ }
+ store.setWorktreeMeta(worktreeId, { firstAgentMessageRenameError: error })
+ // Why: the hook only knows the worktreeId, so derive the repoId notifyBranchRenamed expects.
+ runtime.notifyBranchRenamed(getRepoIdFromWorktreeId(worktreeId))
+ },
+ resolveWorktreeIdForTab: (tabId) => store.getWorktreeIdForTab(tabId),
+ onRenamed: (repoIdOrWorktreeId) => {
+ if (parseWorkspaceKey(repoIdOrWorktreeId)?.type === 'folder') {
+ runtime.notifyFolderWorkspaceChanged()
+ return
+ }
+ runtime.notifyBranchRenamed(repoIdOrWorktreeId)
+ }
+ }
+}
diff --git a/src/main/agent-hooks/first-work-structured-session-rename.ts b/src/main/agent-hooks/first-work-structured-session-rename.ts
new file mode 100644
index 00000000000..637dc1e9c3c
--- /dev/null
+++ b/src/main/agent-hooks/first-work-structured-session-rename.ts
@@ -0,0 +1,28 @@
+import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire'
+import {
+ maybeAutoRenameBranchOnFirstWork,
+ type FirstWorkBranchRenameDeps
+} from './first-work-branch-rename'
+
+export function maybeAutoRenameWorkspaceOnFirstStructuredTurn(
+ summary: AgentSessionStatusSummary,
+ options: { replay: boolean },
+ deps: FirstWorkBranchRenameDeps
+): Promise | undefined {
+ if (summary.status !== 'working') {
+ return
+ }
+ return maybeAutoRenameBranchOnFirstWork(
+ {
+ // No pane: a structured session is resolved by its workspace id, not by a terminal tab.
+ paneKey: '',
+ tabId: undefined,
+ worktreeId: summary.workspaceId,
+ state: 'working',
+ prompt: summary.latestPrompt,
+ assistantMessage: undefined,
+ isReplay: options.replay
+ },
+ deps
+ )
+}
diff --git a/src/main/agent-hooks/first-work-workspace-title-rename.ts b/src/main/agent-hooks/first-work-workspace-title-rename.ts
index fb682c86cf3..063c6f15aec 100644
--- a/src/main/agent-hooks/first-work-workspace-title-rename.ts
+++ b/src/main/agent-hooks/first-work-workspace-title-rename.ts
@@ -27,6 +27,7 @@ export async function runFolderWorkspaceTitleAutoRename(
return stop('folder workspace path unavailable')
}
+ const originalDisplayName = deps.getCurrentDisplayName(worktreeId)
const settings = deps.getSettings()
const resolvedParams = resolveTextGenerationParams(settings, 'local', 'branchName', null)
if (!resolvedParams.ok) {
@@ -49,6 +50,13 @@ export async function runFolderWorkspaceTitleAutoRename(
resolvedParams.params,
target
)
+ // Generation may outlive a manual rename or workspace removal.
+ if (
+ deps.isPendingFirstAgentMessageRename?.(worktreeId) !== true ||
+ deps.getCurrentDisplayName(worktreeId) !== originalDisplayName
+ ) {
+ return stop('folder workspace changed during generation', true)
+ }
if (!generated.success) {
if (!generated.canceled) {
deps.setRenameError(worktreeId, generated.error, generated.failureOutput ?? null)
diff --git a/src/main/browser/agent-browser-bridge-execution.ts b/src/main/browser/agent-browser-bridge-execution.ts
index 65f64b6fb08..31f5a191ede 100644
--- a/src/main/browser/agent-browser-bridge-execution.ts
+++ b/src/main/browser/agent-browser-bridge-execution.ts
@@ -12,6 +12,7 @@ import {
import { translateResult } from './agent-browser-bridge-result'
import { AgentBrowserBridgeTabs } from './agent-browser-bridge-tabs'
import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep'
+import { canSkipAgentBrowserSessionReset } from './agent-browser-session-reset'
import {
STALE_SESSION_CLOSE_TIMEOUT_MS,
type AgentBrowserExecOptions,
@@ -173,6 +174,15 @@ export abstract class AgentBrowserBridgeExecution extends AgentBrowserBridgeTabs
}
protected closeStaleAgentBrowserSession(sessionName: string): Promise {
+ if (
+ canSkipAgentBrowserSessionReset({
+ ownsSocketDirectory: this.ownsAgentBrowserSocketDirectory,
+ socketDirectory: this.agentBrowserEnv.AGENT_BROWSER_SOCKET_DIR,
+ sessionName
+ })
+ ) {
+ return Promise.resolve()
+ }
return new Promise((resolve, reject) => {
let child: ReturnType | null = null
let settled = false
diff --git a/src/main/browser/agent-browser-bridge-session-lifecycle.test.ts b/src/main/browser/agent-browser-bridge-session-lifecycle.test.ts
index 5eab202541c..2955cb66263 100644
--- a/src/main/browser/agent-browser-bridge-session-lifecycle.test.ts
+++ b/src/main/browser/agent-browser-bridge-session-lifecycle.test.ts
@@ -1,18 +1,26 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
-const { execFileMock, webContentsFromIdMock, existsSyncMock, readFileSyncMock, stdinWrites } =
- vi.hoisted(() => ({
- execFileMock: vi.fn(),
- webContentsFromIdMock: vi.fn(),
- existsSyncMock: vi.fn(() => false),
- readFileSyncMock: vi.fn(() => Buffer.from('')),
- stdinWrites: [] as string[]
- }))
+const {
+ execFileMock,
+ webContentsFromIdMock,
+ existsSyncMock,
+ readFileSyncMock,
+ lstatSyncMock,
+ stdinWrites
+} = vi.hoisted(() => ({
+ execFileMock: vi.fn(),
+ webContentsFromIdMock: vi.fn(),
+ existsSyncMock: vi.fn(() => false),
+ readFileSyncMock: vi.fn(() => Buffer.from('')),
+ lstatSyncMock: vi.fn(),
+ stdinWrites: [] as string[]
+}))
vi.mock('child_process', () => ({ execFile: execFileMock }))
vi.mock('fs', () => ({
existsSync: existsSyncMock,
readFileSync: readFileSyncMock,
+ lstatSync: lstatSyncMock,
accessSync: vi.fn(),
chmodSync: vi.fn(),
constants: { X_OK: 1 }
@@ -73,6 +81,14 @@ function closeCallCount(): number {
describe('AgentBrowserBridge', () => {
let bridge: AgentBrowserBridge
+ // The mocked fs has no mkdirSync, so the constructor never claims a socket directory itself.
+ function ownSocketDirectory(): void {
+ Object.assign(bridge, {
+ ownsAgentBrowserSocketDirectory: true,
+ agentBrowserEnv: { AGENT_BROWSER_SOCKET_DIR: '/tmp/orca-ab-test' }
+ })
+ }
+
beforeEach(() => {
resetAgentBrowserBridgeMocks({
webContentsFromIdMock,
@@ -81,11 +97,29 @@ describe('AgentBrowserBridge', () => {
stdinWrites,
cdpWsProxyInstances: CdpWsProxyMock.instances
})
+ // Default to a socket that exists so an unprepared test still takes the reset path.
+ lstatSyncMock.mockReset()
+ lstatSyncMock.mockReturnValue({})
bridge = new AgentBrowserBridge(mockBrowserManager())
bridge.setActiveTab(100)
})
+ it('snapshots a fresh owned session without launching a helper just to close it', async () => {
+ ownSocketDirectory()
+ lstatSyncMock.mockImplementation(() => {
+ throw Object.assign(new Error('No socket'), { code: 'ENOENT' })
+ })
+ webContentsFromIdMock.mockReturnValue(mockWebContents(100))
+ succeedWith({ snapshot: 'ready' })
+
+ expect(await bridge.snapshot()).toMatchObject({ snapshot: 'ready' })
+ expect(closeCallCount()).toBe(0)
+ expect(lstatSyncMock).toHaveBeenCalledWith('/tmp/orca-ab-test/orca-tab-tab-1.sock')
+ })
+
it('fails closed when stale agent-browser session ownership cannot be reset', async () => {
+ ownSocketDirectory()
+ lstatSyncMock.mockReturnValue({})
vi.useFakeTimers()
try {
const closeKill = vi.fn()
diff --git a/src/main/browser/agent-browser-session-reset.test.ts b/src/main/browser/agent-browser-session-reset.test.ts
new file mode 100644
index 00000000000..b38822d5175
--- /dev/null
+++ b/src/main/browser/agent-browser-session-reset.test.ts
@@ -0,0 +1,51 @@
+import { beforeEach, expect, it, vi } from 'vitest'
+import { join } from 'node:path'
+
+const { lstatSync } = vi.hoisted(() => ({ lstatSync: vi.fn() }))
+vi.mock('node:fs', () => ({ lstatSync }))
+import { canSkipAgentBrowserSessionReset } from './agent-browser-session-reset'
+
+const owned = {
+ ownsSocketDirectory: true,
+ socketDirectory: '/tmp/orca-ab-profile',
+ sessionName: 'orca-tab-page'
+}
+const socketPath = join(owned.socketDirectory, 'orca-tab-page.sock')
+
+beforeEach(() => {
+ lstatSync.mockReset()
+})
+
+it('skips an absent owned socket', () => {
+ lstatSync.mockImplementation(() => {
+ throw Object.assign(new Error('No socket'), { code: 'ENOENT' })
+ })
+ expect(canSkipAgentBrowserSessionReset(owned)).toBe(true)
+ expect(lstatSync).toHaveBeenCalledWith(socketPath)
+})
+
+it('requires reset when a socket or symlink exists', () => {
+ lstatSync.mockReturnValue({})
+ expect(canSkipAgentBrowserSessionReset(owned)).toBe(false)
+ expect(lstatSync).toHaveBeenCalledWith(socketPath)
+})
+
+it.each(['EACCES', 'EIO', 'ENOTDIR'])('requires reset for %s', (code) => {
+ lstatSync.mockImplementation(() => {
+ throw Object.assign(new Error('Socket inspection failed'), { code })
+ })
+ expect(canSkipAgentBrowserSessionReset(owned)).toBe(false)
+ expect(lstatSync).toHaveBeenCalledWith(socketPath)
+})
+
+// Windows and inherited socket directories both arrive as ownsSocketDirectory: false.
+it.each([
+ { ownsSocketDirectory: false },
+ { socketDirectory: undefined },
+ { sessionName: '../other' },
+ { sessionName: 'has space' },
+ { sessionName: '' }
+])('requires reset without an owned Unix socket address: %j', (override) => {
+ expect(canSkipAgentBrowserSessionReset({ ...owned, ...override })).toBe(false)
+ expect(lstatSync).not.toHaveBeenCalled()
+})
diff --git a/src/main/browser/agent-browser-session-reset.ts b/src/main/browser/agent-browser-session-reset.ts
new file mode 100644
index 00000000000..8c6b7545f02
--- /dev/null
+++ b/src/main/browser/agent-browser-session-reset.ts
@@ -0,0 +1,30 @@
+import { lstatSync } from 'node:fs'
+import { join } from 'node:path'
+
+// agent-browser's own session-name rule; doubles as a traversal fence for the `join` below.
+const SAFE_SESSION_NAME = /^[A-Za-z0-9_-]+$/
+
+/**
+ * True when no daemon can be holding `sessionName`, so closing it would only start one.
+ *
+ * Only an Orca-derived socket directory proves that (`ownsSocketDirectory`): it is a
+ * private per-profile `/tmp` directory, never an inherited one shared with a second
+ * profile, and never Windows, which uses named pipes and leaves no socket to inspect.
+ */
+export function canSkipAgentBrowserSessionReset(options: {
+ ownsSocketDirectory: boolean
+ socketDirectory: string | undefined
+ sessionName: string
+}): boolean {
+ const { socketDirectory, sessionName } = options
+ if (!options.ownsSocketDirectory || !socketDirectory || !SAFE_SESSION_NAME.test(sessionName)) {
+ return false
+ }
+ try {
+ lstatSync(join(socketDirectory, `${sessionName}.sock`))
+ return false
+ } catch (error) {
+ // Only a proven-absent socket is safe to skip; permission and other failures prove nothing.
+ return (error as NodeJS.ErrnoException).code === 'ENOENT'
+ }
+}
diff --git a/src/main/claude/claude-slash-command-catalog.test.ts b/src/main/claude/claude-slash-command-catalog.test.ts
new file mode 100644
index 00000000000..20d79f9f53d
--- /dev/null
+++ b/src/main/claude/claude-slash-command-catalog.test.ts
@@ -0,0 +1,132 @@
+import { describe, expect, it } from 'vitest'
+import { ClaudeSlashCommandCatalog, readClaudeSlashCommands } from './claude-slash-command-catalog'
+
+function init(overrides: Record = {}): Record {
+ return {
+ type: 'system',
+ subtype: 'init',
+ session_id: 'provider-1',
+ slash_commands: ['clear', 'ref-oss', 'doctor', 'opsx:apply'],
+ terminal_slash_commands: ['doctor'],
+ skills: ['ref-oss', 'doctor'],
+ ...overrides
+ }
+}
+
+describe('claude slash command catalog', () => {
+ it('tags reported skills and drops the commands reserved for a terminal UI', () => {
+ expect(readClaudeSlashCommands(init())).toEqual([
+ { name: 'clear', kind: 'command' },
+ { name: 'ref-oss', kind: 'skill' },
+ { name: 'opsx:apply', kind: 'command' }
+ ])
+ })
+
+ it('rejects blank, whitespace-carrying and duplicate names', () => {
+ expect(
+ readClaudeSlashCommands(
+ init({ slash_commands: ['clear', ' ', 'two words', 'clear'], skills: [] })
+ )
+ ).toEqual([{ name: 'clear', kind: 'command' }])
+ })
+
+ it('seeds from the init frame that proved the session', () => {
+ expect(new ClaudeSlashCommandCatalog(init()).commands).toHaveLength(3)
+ expect(new ClaudeSlashCommandCatalog().commands).toBeUndefined()
+ // A frame of the right subtype but without the array is not a catalog.
+ expect(
+ new ClaudeSlashCommandCatalog({ type: 'system', subtype: 'init' }).commands
+ ).toBeUndefined()
+ })
+
+ it('replaces the catalog on commands_changed and reports only real changes', () => {
+ const catalog = new ClaudeSlashCommandCatalog(init())
+ expect(catalog.observe(init())).toBe(false)
+ expect(catalog.observe({ type: 'assistant', slash_commands: ['other'] })).toBe(false)
+ expect(
+ catalog.observe({
+ type: 'system',
+ subtype: 'commands_changed',
+ slash_commands: ['clear', 'brand-new'],
+ skills: ['brand-new']
+ })
+ ).toBe(true)
+ expect(catalog.commands).toEqual([
+ { name: 'clear', kind: 'command' },
+ { name: 'brand-new', kind: 'skill' }
+ ])
+ })
+
+ it('notices a name that only changed kind', () => {
+ const catalog = new ClaudeSlashCommandCatalog(
+ init({ slash_commands: ['review'], skills: [], terminal_slash_commands: [] })
+ )
+ expect(
+ catalog.observe({
+ type: 'system',
+ subtype: 'commands_changed',
+ slash_commands: ['review'],
+ skills: ['review']
+ })
+ ).toBe(true)
+ expect(catalog.commands).toEqual([{ name: 'review', kind: 'skill' }])
+ })
+})
+
+it('accepts descriptor reloads, removing old skills while retaining terminal filtering', () => {
+ const catalog = new ClaudeSlashCommandCatalog(init())
+ const reload = {
+ type: 'system',
+ subtype: 'commands_changed',
+ commands: [
+ { name: 'clear', description: 'Clear', argumentHint: '' },
+ { name: 'new-skill', description: 'New', argumentHint: '' },
+ { name: 'doctor', description: 'Terminal', argumentHint: '' }
+ ]
+ }
+ expect(catalog.observe(reload)).toBe(true)
+ expect(catalog.commands).toEqual([
+ { name: 'clear', kind: 'command' },
+ { name: 'new-skill', kind: 'skill' }
+ ])
+ expect(catalog.observe(reload)).toBe(false)
+ expect(catalog.observe({ ...reload, commands: [] })).toBe(true)
+ expect(catalog.commands).toEqual([])
+})
+
+it('lets stream init refine a control seed and preserves kinds across descriptor reloads', () => {
+ const seed = { commands: [{ name: 'clear' }, { name: 'project-check' }] }
+ const catalog = new ClaudeSlashCommandCatalog(undefined, seed)
+ expect(catalog.commands).toEqual([
+ { name: 'clear', kind: 'command', kindUnspecified: true },
+ { name: 'project-check', kind: 'command', kindUnspecified: true }
+ ])
+ expect(catalog.observe({ type: 'system', subtype: 'commands_changed', ...seed })).toBe(false)
+ const fullInit = init({ slash_commands: ['clear', 'project-check'], skills: ['project-check'] })
+ expect(catalog.observe(fullInit)).toBe(true)
+ expect(catalog.commands).toEqual([
+ { name: 'clear', kind: 'command' },
+ { name: 'project-check', kind: 'skill' }
+ ])
+ expect(catalog.observe({ type: 'system', subtype: 'commands_changed', ...seed })).toBe(false)
+ expect(new ClaudeSlashCommandCatalog(fullInit, seed).commands).toEqual(catalog.commands)
+ expect(new ClaudeSlashCommandCatalog(init({ slash_commands: [] }), seed).commands).toEqual([])
+})
+
+it('distinguishes missing or malformed control catalogs from authoritative empty ones', () => {
+ for (const initialization of [undefined, null, {}, { commands: null }, { commands: 'bad' }]) {
+ expect(new ClaudeSlashCommandCatalog(undefined, initialization).commands).toBeUndefined()
+ }
+ expect(new ClaudeSlashCommandCatalog(undefined, { commands: [] }).commands).toEqual([])
+ expect(
+ new ClaudeSlashCommandCatalog(undefined, {
+ commands: [null, {}, { name: ' ' }, { name: 'two words' }, { name: 'ok' }, { name: 'ok' }]
+ }).commands
+ ).toEqual([{ name: 'ok', kind: 'command', kindUnspecified: true }])
+})
+
+it('publishes classification becoming authoritative even when the name and kind stay unchanged', () => {
+ const catalog = new ClaudeSlashCommandCatalog(undefined, { commands: [{ name: 'clear' }] })
+ expect(catalog.observe(init({ slash_commands: ['clear'], skills: [] }))).toBe(true)
+ expect(catalog.commands).toEqual([{ name: 'clear', kind: 'command' }])
+})
diff --git a/src/main/claude/claude-slash-command-catalog.ts b/src/main/claude/claude-slash-command-catalog.ts
new file mode 100644
index 00000000000..b1f65d93d50
--- /dev/null
+++ b/src/main/claude/claude-slash-command-catalog.ts
@@ -0,0 +1,123 @@
+import type { AgentSessionSlashCommand } from '../../shared/agent-session-wire'
+
+// Stream init carries name arrays; control initialization and reloads carry descriptors.
+const MAX_COMMANDS = 512
+const MAX_NAME_LENGTH = 200
+
+function names(value: unknown): string[] {
+ if (!Array.isArray(value)) {
+ return []
+ }
+ const seen = new Set()
+ for (const entry of value) {
+ if (seen.size >= MAX_COMMANDS) {
+ break
+ }
+ const name = typeof entry === 'string' ? entry.trim() : ''
+ if (name.length > 0 && name.length <= MAX_NAME_LENGTH && !/\s/u.test(name)) {
+ seen.add(name)
+ }
+ }
+ return [...seen]
+}
+
+function descriptorNames(value: unknown): string[] {
+ return names(
+ Array.isArray(value)
+ ? value.map((entry) => (entry !== null && typeof entry === 'object' ? entry.name : undefined))
+ : []
+ )
+}
+
+function carriesCommandCatalog(message: Record): boolean {
+ return (
+ message.type === 'system' &&
+ (message.subtype === 'init' || message.subtype === 'commands_changed') &&
+ Array.isArray(message.slash_commands)
+ )
+}
+
+/** What the session reports it can run, minus what it reserves for a terminal UI. */
+export function readClaudeSlashCommands(
+ message: Record
+): AgentSessionSlashCommand[] {
+ // Why: the hide-list exists so a non-terminal UI like chat does not offer a
+ // command that only means something inside the CLI's own TUI.
+ const hidden = new Set(names(message.terminal_slash_commands))
+ const skills = new Set(names(message.skills))
+ return names(message.slash_commands)
+ .filter((name) => !hidden.has(name))
+ .map((name) => ({ name, kind: skills.has(name) ? ('skill' as const) : ('command' as const) }))
+}
+
+/** Per-session catalog seeded during acquisition and refreshed by provider frames. */
+export class ClaudeSlashCommandCatalog {
+ private entries: AgentSessionSlashCommand[] | undefined
+ private hasSkillClassification = false
+ private hidden = new Set()
+ private commandNames = new Set()
+
+ constructor(initMessage?: Record, initialization?: unknown) {
+ // SessionStart can prove acquisition before the first stream init exists.
+ if (
+ initialization !== null &&
+ typeof initialization === 'object' &&
+ 'commands' in initialization &&
+ Array.isArray(initialization.commands)
+ ) {
+ this.entries = descriptorNames(initialization.commands).map((name) => ({
+ name,
+ kind: 'command',
+ kindUnspecified: true
+ }))
+ }
+ if (initMessage) {
+ this.observe(initMessage)
+ }
+ }
+
+ get commands(): AgentSessionSlashCommand[] | undefined {
+ return this.entries
+ }
+
+ /** True when this frame replaced the catalog with a different one. */
+ observe(message: Record): boolean {
+ let next: AgentSessionSlashCommand[]
+ if (carriesCommandCatalog(message)) {
+ this.hasSkillClassification = true
+ this.hidden = new Set(names(message.terminal_slash_commands))
+ next = readClaudeSlashCommands(message)
+ this.commandNames = new Set(
+ next.filter((entry) => entry.kind === 'command').map((entry) => entry.name)
+ )
+ } else if (
+ message.type === 'system' &&
+ message.subtype === 'commands_changed' &&
+ Array.isArray(message.commands)
+ ) {
+ next = descriptorNames(message.commands)
+ .filter((name) => !this.hidden.has(name))
+ .map((name) =>
+ this.hasSkillClassification
+ ? { name, kind: this.commandNames.has(name) ? 'command' : 'skill' }
+ : { name, kind: 'command', kindUnspecified: true }
+ )
+ } else {
+ return false
+ }
+ if (
+ this.entries !== undefined &&
+ next.length === this.entries.length &&
+ next.every(
+ (entry, index) =>
+ entry.name === this.entries?.[index]?.name &&
+ entry.kind === this.entries?.[index]?.kind &&
+ entry.kindUnspecified === this.entries?.[index]?.kindUnspecified
+ )
+ ) {
+ return false
+ }
+ this.entries = next
+ return true
+ }
+}
diff --git a/src/main/claude/claude-structured-content-parts.test.ts b/src/main/claude/claude-structured-content-parts.test.ts
index d2142150937..1d9ed800e0f 100644
--- a/src/main/claude/claude-structured-content-parts.test.ts
+++ b/src/main/claude/claude-structured-content-parts.test.ts
@@ -47,6 +47,92 @@ const BASE64_IMAGE = {
}
describe('Claude message content parts', () => {
+ it.each(['isMeta', 'isSynthetic', 'isCompactSummary'])(
+ 'consumes %s skill context without a user bubble, fallback, or new turn',
+ (flag) => {
+ const state = sinkState()
+ const translator = createClaudeJournalTranslator({ sink: state.sink })
+ const event = userMessageWith({ type: 'text', text: '# Skill instructions' })
+ translator.handle({ ...event, message: { ...event.message, [flag]: true } })
+ expect(state.items).toEqual([])
+ expect(state.sink.publish).not.toHaveBeenCalled()
+ }
+ )
+
+ it('keeps tool results in an injected skill message', () => {
+ const state = sinkState()
+ const translator = createClaudeJournalTranslator({ sink: state.sink })
+ const event = userMessageWith({
+ type: 'tool_result',
+ tool_use_id: 'skill-call',
+ content: 'Skill loaded'
+ })
+ translator.handle({ ...event, message: { ...event.message, isMeta: true } })
+ expect(state.items.map((item) => item.body)).toEqual([
+ expect.objectContaining({
+ kind: 'tool-call',
+ state: 'completed',
+ output: expect.objectContaining({ head: 'Skill loaded', truncated: false })
+ })
+ ])
+ })
+
+ it.each([
+ { content: '# Skill instructions' },
+ { content: [{ type: 'future_context', text: '# Skill instructions' }] },
+ {
+ content: [
+ { type: 'text', text: '[Image: source: /tmp/pasted.png]' },
+ { type: 'text', text: '# Skill instructions' }
+ ]
+ }
+ ])('does not surface injected content as text or a provider fallback: %j', ({ content }) => {
+ const state = sinkState()
+ const translator = createClaudeJournalTranslator({ sink: state.sink })
+ const event = userMessageWith(null)
+ translator.handle({
+ ...event,
+ message: { ...event.message, isMeta: true, message: { role: 'user', content } }
+ })
+ expect(state.items).toEqual([])
+ })
+
+ it('does not render user echoes even without metadata flags', () => {
+ const state = sinkState()
+ const translator = createClaudeJournalTranslator({ sink: state.sink })
+ for (const content of ['/example-skill', '# Skill instructions']) {
+ const event = userMessageWith(null)
+ translator.handle({
+ ...event,
+ message: { ...event.message, message: { role: 'user', content } }
+ })
+ }
+ expect(state.items.flatMap(({ body }) => (body.kind === 'message' ? body.blocks : []))).toEqual(
+ []
+ )
+ })
+
+ it('silently consumes unmarked user context with unknown content parts', () => {
+ const state = sinkState()
+ const translator = createClaudeJournalTranslator({ sink: state.sink })
+ const event = userMessageWith({ type: 'future_context', text: 'Expanded instructions' })
+ translator.handle({ ...event, startsTurn: undefined })
+ expect(state.items).toEqual([])
+ expect(state.sink.publish).not.toHaveBeenCalled()
+ })
+
+ it('does not render injected image companions or start a turn', () => {
+ const state = sinkState()
+ const translator = createClaudeJournalTranslator({ sink: state.sink })
+ const event = userMessageWith(null)
+ const content = [{ type: 'text', text: '[Image: source: /tmp/pasted.png]' }]
+ translator.handle({
+ ...event,
+ message: { ...event.message, isMeta: true, message: { role: 'user', content } }
+ })
+ expect(state.items).toEqual([])
+ })
+
it('does not leak a wire kind for a locally attached image', () => {
const state = sinkState()
const translator = createClaudeJournalTranslator({ sink: state.sink })
@@ -56,7 +142,7 @@ describe('Claude message content parts', () => {
expect(providerRows(state.items)).toEqual([])
})
- it('still renders an image the CLI sends by url', () => {
+ it('does not render echoed image URLs', () => {
const state = sinkState()
const translator = createClaudeJournalTranslator({ sink: state.sink })
@@ -67,21 +153,29 @@ describe('Claude message content parts', () => {
expect(providerRows(state.items)).toEqual([])
expect(
state.items.flatMap((item) => (item.body.kind === 'message' ? item.body.blocks : []))
- ).toContainEqual({ type: 'image-ref', url: 'https://x.test/a.png' })
+ ).toEqual([])
})
it('says what is true for a content part it cannot render, not the wire kind', () => {
const state = sinkState()
const translator = createClaudeJournalTranslator({ sink: state.sink })
- translator.handle(userMessageWith({ type: 'some_future_part', payload: { a: 1 } }))
+ const event = userMessageWith(null)
+ translator.handle({
+ ...event,
+ message: {
+ ...event.message,
+ type: 'assistant',
+ message: { role: 'assistant', content: [{ type: 'some_future_part', payload: { a: 1 } }] }
+ }
+ })
const rows = providerRows(state.items)
expect(rows).toHaveLength(1)
// The kind stays on the row for debugging, behind the disclosure.
- expect(rows[0].kind).toBe('message:user:content:some_future_part')
+ expect(rows[0].kind).toBe('message:assistant:content:some_future_part')
// ...but the visible text is a sentence, not the opcode.
- expect(rows[0].text).not.toContain('message:user:content')
+ expect(rows[0].text).not.toContain('message:assistant:content')
expect(rows[0].text.toLowerCase()).toContain('claude')
})
@@ -89,9 +183,18 @@ describe('Claude message content parts', () => {
const state = sinkState()
const translator = createClaudeJournalTranslator({ sink: state.sink })
- translator.handle(
- userMessageWith({ type: 'some_future_part', message: 'the server refused the upload' })
- )
+ const event = userMessageWith(null)
+ translator.handle({
+ ...event,
+ message: {
+ ...event.message,
+ type: 'assistant',
+ message: {
+ role: 'assistant',
+ content: [{ type: 'some_future_part', message: 'the server refused the upload' }]
+ }
+ }
+ })
expect(providerRows(state.items)[0].text).toBe('the server refused the upload')
})
diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts
index d66a64f82eb..ad09357df58 100644
--- a/src/main/claude/claude-structured-dispatch.test.ts
+++ b/src/main/claude/claude-structured-dispatch.test.ts
@@ -7,6 +7,7 @@ import { dispatchClaudeTurn, resolveClaudeReplayWaiter } from './claude-structur
import { readClaudeImage } from './claude-structured-dispatch-content'
import type { ClaudeSession } from './claude-structured-session-state'
import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker'
+import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog'
function sessionFor(send = vi.fn().mockResolvedValue(undefined)): ClaudeSession {
return {
@@ -21,6 +22,7 @@ function sessionFor(send = vi.fn().mockResolvedValue(undefined)): ClaudeSession
retiredDispatchWaiters: [],
replayContentFallbackBlocked: false,
backgroundTasks: new ClaudeBackgroundTaskTracker(),
+ commands: new ClaudeSlashCommandCatalog(),
dispatchSequence: 0,
optionMutationSequence: 0,
options: new Map(),
@@ -49,6 +51,28 @@ function userReplayFrame(uuid: string, text: string): Record {
}
describe('Claude structured dispatch image limits', () => {
+ it.each(['isMeta', 'isSynthetic', 'isCompactSummary'])(
+ 'does not acknowledge a dispatch with %s context even when the client uuid matches',
+ async (flag) => {
+ const session = sessionFor()
+ const dispatched = dispatchClaudeTurn(
+ session,
+ { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/example' }]) },
+ 1000
+ )
+ await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
+ const sentUuid = session.dispatchWaiters[0]!.sentUuid
+ const replay = userReplayFrame(sentUuid, '/example')
+ expect(resolveClaudeReplayWaiter(session, { ...replay, [flag]: true })).toBe(false)
+ expect(session.dispatchWaiters).toHaveLength(1)
+ expect(resolveClaudeReplayWaiter(session, replay)).toBe(true)
+ await expect(dispatched).resolves.toMatchObject({
+ state: 'accepted',
+ providerIdentity: { uuid: sentUuid }
+ })
+ }
+ )
+
it('recovers the active identity when a timed-out replay arrives late', async () => {
const session = sessionFor()
const dispatched = dispatchClaudeTurn(
@@ -65,6 +89,60 @@ describe('Claude structured dispatch image limits', () => {
expect(session.activeTurnSequence).toBe(session.dispatchSequence)
})
+ it('settles the send a timed-out replay proves was delivered', async () => {
+ const session = sessionFor()
+ const settled = vi.fn()
+ const dispatched = dispatchClaudeTurn(
+ session,
+ { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) },
+ 500
+ )
+ await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
+ const sentUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid
+ await expect(dispatched).resolves.toMatchObject({ state: 'unknown' })
+
+ resolveClaudeReplayWaiter(session, userReplayFrame(sentUuid!, 'one'), settled)
+ expect(settled).toHaveBeenCalledWith({
+ clientMessageId: 'client-1',
+ providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: sentUuid }
+ })
+ })
+
+ it('settles a superseded dispatch even though it no longer owns the turn identity', async () => {
+ const session = sessionFor()
+ const settled = vi.fn()
+ const first = dispatchClaudeTurn(
+ session,
+ { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) },
+ 500
+ )
+ await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
+ const firstUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid
+ await expect(first).resolves.toMatchObject({ state: 'unknown' })
+
+ const second = dispatchClaudeTurn(
+ session,
+ { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'two' }]) },
+ 100
+ )
+ await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
+ const secondUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid
+
+ // The stale replay must not claim the active turn, but the message it names
+ // did land, so the send it came from is delivered and must stop reading as
+ // unconfirmed — that banner is what makes a user resend a duplicate.
+ expect(resolveClaudeReplayWaiter(session, userReplayFrame(firstUuid!, 'one'), settled)).toBe(
+ false
+ )
+ expect(settled).toHaveBeenCalledWith({
+ clientMessageId: 'client-1',
+ providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: firstUuid }
+ })
+ resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid!, 'two'), settled)
+ await expect(second).resolves.toMatchObject({ state: 'accepted' })
+ expect(settled).toHaveBeenCalledTimes(1)
+ })
+
it('never lets a late replay for dispatch A resolve dispatch B', async () => {
const session = sessionFor()
const first = dispatchClaudeTurn(
diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts
index 96271e41d71..b7619a1e94e 100644
--- a/src/main/claude/claude-structured-dispatch.ts
+++ b/src/main/claude/claude-structured-dispatch.ts
@@ -1,5 +1,8 @@
import { randomUUID } from 'node:crypto'
-import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
+import type {
+ AgentJournalItemIdentity,
+ AgentJournalMessageItem
+} from '../../shared/agent-session-journal-types'
import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import {
claudeHasReplayContent,
@@ -14,9 +17,16 @@ import {
const MAX_RETIRED_DISPATCH_WAITERS = 64
+/** A dispatch whose ack window expired, proven delivered by this replay. */
+export type ClaudeLateDispatchSettlement = (input: {
+ clientMessageId: string
+ providerIdentity: AgentJournalItemIdentity
+}) => void
+
export function resolveClaudeReplayWaiter(
session: ClaudeSession,
- message: Record
+ message: Record,
+ onSettledLate?: ClaudeLateDispatchSettlement
): boolean {
const envelope = readClaudeMessageEnvelope(message)
const isUserReplay =
@@ -52,7 +62,7 @@ export function resolveClaudeReplayWaiter(
)
if (retired) {
forgetRetiredWaiter(session, retired)
- return recoverLateIdentity(session, retired, uuid, isUserReplay)
+ return recoverLateIdentity(session, retired, uuid, isUserReplay, onSettledLate)
}
return false
}
@@ -65,7 +75,7 @@ export function resolveClaudeReplayWaiter(
const retired = session.retiredDispatchWaiters.find((candidate) => candidate.sentUuid === uuid)
if (retired) {
forgetRetiredWaiter(session, retired)
- return recoverLateIdentity(session, retired, uuid, isUserReplay)
+ return recoverLateIdentity(session, retired, uuid, isUserReplay, onSettledLate)
}
if (isUserReplay) {
@@ -89,7 +99,7 @@ export function resolveClaudeReplayWaiter(
if (lateCompatible.length === 1) {
const [candidate] = lateCompatible
forgetRetiredWaiter(session, candidate!)
- return recoverLateIdentity(session, candidate!, uuid, true)
+ return recoverLateIdentity(session, candidate!, uuid, true, onSettledLate)
}
}
return false
@@ -138,11 +148,19 @@ function recoverLateIdentity(
session: ClaudeSession,
waiter: ClaudeDispatchWaiter,
uuid: string,
- isUserReplay: boolean
+ isUserReplay: boolean,
+ onSettledLate?: ClaudeLateDispatchSettlement
): boolean {
if (!isUserReplay && !waiter.acceptsResult) {
return false
}
+ // The provider acted on this dispatch, so the send it came from is delivered.
+ // Unfenced on purpose: the dispatch-sequence check below only decides which
+ // turn owns the identity, while delivery is settled for good either way.
+ onSettledLate?.({
+ clientMessageId: waiter.clientMessageId,
+ providerIdentity: { provider: 'claude', sessionId: session.providerSessionId, uuid }
+ })
if (waiter.dispatchSequence === session.dispatchSequence) {
session.activeTurnId = uuid
session.activeTurnSequence = waiter.dispatchSequence
@@ -155,12 +173,14 @@ function waitForReplay(
timeoutMs: number,
acceptsResult: boolean,
sentUuid: string,
- replayContentKey: string
+ replayContentKey: string,
+ clientMessageId: string
): { waiter: ClaudeDispatchWaiter; promise: Promise } {
let waiter!: ClaudeDispatchWaiter
const promise = new Promise((resolve) => {
waiter = {
acceptsResult,
+ clientMessageId,
sentUuid,
dispatchSequence: session.dispatchSequence,
replayContentKey,
@@ -220,7 +240,8 @@ export async function dispatchClaudeTurn(
timeoutMs,
acceptsResult,
sentUuid,
- claudeDispatchContentKey(content)
+ claudeDispatchContentKey(content),
+ input.clientMessageId
)
const replayed = replay.promise
try {
diff --git a/src/main/claude/claude-structured-item-translation.ts b/src/main/claude/claude-structured-item-translation.ts
index d86093ee0a5..c0ce20908d8 100644
--- a/src/main/claude/claude-structured-item-translation.ts
+++ b/src/main/claude/claude-structured-item-translation.ts
@@ -17,6 +17,7 @@ export type ClaudeMessageEnvelope = {
/** Messages API id shared by every frame of one streamed assistant message. */
messageId: string | null
parentToolUseId: string | null
+ isInjectedUserTurn?: boolean
}
export type ClaudeToolUse = { id: string; name: string; input: unknown }
@@ -42,12 +43,16 @@ export function readClaudeMessageEnvelope(
const sessionId = claudeText(frame.session_id)
const uuid = claudeText(frame.uuid)
const role = message?.role
+ const isInjectedUserTurn =
+ frame.type === 'user' &&
+ (frame.isMeta === true || frame.isSynthetic === true || frame.isCompactSummary === true)
return sessionId && uuid && (role === 'assistant' || role === 'user')
? {
sessionId,
uuid,
role,
content: messageContent(message?.content),
+ isInjectedUserTurn,
messageId: claudeText(message?.id),
parentToolUseId: claudeText(frame.parent_tool_use_id)
}
@@ -93,6 +98,9 @@ export function claudeMessageBody(envelope: ClaudeMessageEnvelope): AgentJournal
}
export function claudeHasReplayContent(envelope: ClaudeMessageEnvelope): boolean {
+ if (envelope.isInjectedUserTurn) {
+ return false
+ }
return envelope.content.some((value) => {
const part = claudeRecord(value)
return part !== null && part.type !== 'tool_result'
diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts
index f403313dae8..951872f0c69 100644
--- a/src/main/claude/claude-structured-journal-translation.test.ts
+++ b/src/main/claude/claude-structured-journal-translation.test.ts
@@ -342,10 +342,7 @@ describe('Claude structured journal translation', () => {
state.items.flatMap((item) =>
item.body.kind === 'message' && item.body.role === 'user' ? [item.body.blocks] : []
)
- ).toEqual([
- [{ type: 'text', text: 'Reply with exactly PROBE_OK_1 and nothing else.' }],
- [{ type: 'text', text: '[Request interrupted by user]' }]
- ])
+ ).toEqual([])
expect(
state.items.some((item) => item.body.kind === 'status' && !item.body.turnLifecycle)
).toBe(false)
@@ -521,10 +518,7 @@ describe('Claude structured journal translation', () => {
const keyed = new Map(
state.items.map((item) => [agentJournalItemKey(item.identity), item.body])
)
- expect(keyed.get('claude:claude-session:user-1')).toMatchObject({
- kind: 'message',
- role: 'user'
- })
+ expect(keyed.has('claude:claude-session:user-1')).toBe(false)
expect(keyed.get('orca:claude-tool%3Aclaude-session%3Atool-1')).toMatchObject({
kind: 'tool-call',
name: 'Bash',
@@ -683,7 +677,6 @@ describe('Claude structured journal translation', () => {
'message:system:local_command_output',
'message:system:command_started',
'message:result',
- 'message:user:content:document',
'control_request:future_control'
])
)
diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts
index ffaad4da570..df8e8e67f53 100644
--- a/src/main/claude/claude-structured-journal-translation.ts
+++ b/src/main/claude/claude-structured-journal-translation.ts
@@ -30,6 +30,7 @@ import {
} from './claude-structured-prompt-items'
import type { ClaudePromptRegistry } from './claude-structured-prompt-replies'
import { readableProviderFrameText } from '../native-chat/agent-session-wire/unhandled-provider-frame'
+import { claudeProviderFrameActivity } from '../native-chat/agent-session-wire/provider-frame-activity'
import {
CLAUDE_UNRENDERABLE_CONTENT_TEXT,
claudeProviderFrameKind,
@@ -112,7 +113,20 @@ export function createClaudeJournalTranslator(
} else {
deps.sink.appendTombstone(identity)
}
- deps.sink.publish()
+ // Preserve first-work evidence when completion arrives before the journal drains.
+ deps.sink.publish({
+ coalescingKey: running ? `turn-start:${sessionId}:${turnId}` : 'publish'
+ })
+ }
+
+ const publishActivity = (kind: string, payload: unknown): void => {
+ if (!currentTurn) {
+ return
+ }
+ const text = claudeProviderFrameActivity(kind, payload)
+ if (text !== undefined) {
+ deps.sink.setActivity?.(text ? { turnId: currentTurn.turnId, text } : null)
+ }
}
const handleStream = (message: Record): boolean => {
@@ -130,7 +144,15 @@ export function createClaudeJournalTranslator(
return false
}
let changed = false
- const body = claudeMessageBody(envelope)
+ // User bubbles belong to the submitted message; SDK user frames carry echoes and tool results.
+ const outputEnvelope =
+ envelope.role === 'user'
+ ? {
+ ...envelope,
+ content: envelope.content.filter((part) => claudeRecord(part)?.type === 'tool_result')
+ }
+ : envelope
+ const body = claudeMessageBody(outputEnvelope)
// The final frame of a streamed block lands on the block's identity, not its own uuid.
const identity =
(body && envelope.role === 'assistant' ? streamedBlocks.reconcile(envelope) : null) ??
@@ -140,7 +162,7 @@ export function createClaudeJournalTranslator(
deps.sink.appendItem(identity, body)
changed = true
}
- for (const tool of claudeToolUses(envelope)) {
+ for (const tool of claudeToolUses(outputEnvelope)) {
tools.set(tool.id, tool)
deps.sink.appendItem(
claudeToolIdentity(envelope.sessionId, tool.id),
@@ -162,7 +184,7 @@ export function createClaudeJournalTranslator(
tools.delete(result.toolUseId)
changed = true
}
- const thinking = claudeThinkingText(envelope)
+ const thinking = claudeThinkingText(outputEnvelope)
if (thinking) {
deps.sink.appendItem(claudeThinkingIdentity(envelope.sessionId, envelope.uuid), {
kind: 'status',
@@ -170,7 +192,7 @@ export function createClaudeJournalTranslator(
})
changed = true
}
- const unhandledContent = envelope.content.filter((part) => !isModeledClaudeContent(part))
+ const unhandledContent = outputEnvelope.content.filter((part) => !isModeledClaudeContent(part))
for (const part of unhandledContent) {
const partType = claudeText(claudeRecord(part)?.type) ?? 'unknown'
providerFallback.append(
@@ -196,6 +218,7 @@ export function createClaudeJournalTranslator(
}
currentTurn = { sessionId: envelope.sessionId, turnId: envelope.uuid }
publishLifecycle(envelope.sessionId, envelope.uuid, true)
+ deps.sink.setActivity?.(null)
}
if (changed) {
deps.sink.publish()
@@ -235,6 +258,7 @@ export function createClaudeJournalTranslator(
publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false)
currentTurn = null
}
+ deps.sink.setActivity?.(null)
return
}
if (event.type === 'message' && handleStream(event.message)) {
@@ -254,6 +278,7 @@ export function createClaudeJournalTranslator(
publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false)
currentTurn = null
}
+ deps.sink.setActivity?.(null)
// The turn is over. A block still awaiting its final keeps the text the
// flush above journaled, but its live state goes: an interrupted turn
// would otherwise retain that text for the life of the session.
@@ -266,11 +291,14 @@ export function createClaudeJournalTranslator(
providerFallback.append(kind, event.message, failure?.text)
}
} else if (event.type === 'message') {
+ const kind = claudeProviderFrameKind(event.message)
if (!handleMessage(event.message, event.startsTurn === true)) {
- providerFallback.append(claudeProviderFrameKind(event.message), event.message)
+ providerFallback.append(kind, event.message)
}
+ publishActivity(kind, event.message)
} else if (event.type === 'provider-frame') {
providerFallback.append(event.kind, event.payload)
+ publishActivity(event.kind, event.payload)
}
},
flush: streamedText.flush,
diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts
index 4f28f14ad65..f9160cdb005 100644
--- a/src/main/claude/claude-structured-launch-resolution.ts
+++ b/src/main/claude/claude-structured-launch-resolution.ts
@@ -4,6 +4,7 @@ import type { AgentSessionJournalIdentity } from '../../shared/agent-session-jou
import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution'
+import { structuredWorkerChildIdentityEnv } from '../runtime/structured-worker-child-identity-env'
import {
CLAUDE_AUTH_ENV_CONFLICT_MESSAGE,
CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE,
@@ -236,7 +237,9 @@ export function createClaudeStructuredLaunchResolver(
// user's own key is their sign-in and must reach the child.
const env = withCliRuntimeOnPath(
command,
- {
+ // Only a dispatched structured worker gets the orchestration identity and the Orca CLI on
+ // PATH; an ordinary chat session's env passes through untouched.
+ structuredWorkerChildIdentityEnv(record.sessionId, {
...applyClaudeEnvPatch(
cloneDefinedEnv(process.env),
{},
@@ -246,7 +249,7 @@ export function createClaudeStructuredLaunchResolver(
}
),
...(overlay ? cloneDefinedEnv(overlay) : {})
- },
+ }),
{ platform: process.platform }
)
return {
diff --git a/src/main/claude/claude-structured-location-support.test.ts b/src/main/claude/claude-structured-location-support.test.ts
index 1106667d544..fe3820964e3 100644
--- a/src/main/claude/claude-structured-location-support.test.ts
+++ b/src/main/claude/claude-structured-location-support.test.ts
@@ -56,8 +56,11 @@ describe('supportsClaudeStructuredLocation', () => {
it('accepts Windows local locations once creation-time proof is available', () => {
previousPlatform = setPlatform('win32')
+ // supportedProcessDataFlags is the addon's own report; the enum alone is
+ // not proof, because pnpm patches the source over the tarball's prebuilt.
__setWindowsProcessTreeLoaderForTests(() => ({
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 },
+ supportedProcessDataFlags: 7,
getAllProcesses: () => undefined
}))
expect(
diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts
index 0738095f45d..2375df12d93 100644
--- a/src/main/claude/claude-structured-options.test.ts
+++ b/src/main/claude/claude-structured-options.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import { setClaudeStructuredOption } from './claude-structured-options'
import type { ClaudeSession } from './claude-structured-session-state'
import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker'
+import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog'
function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSession {
return {
@@ -16,6 +17,7 @@ function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSe
retiredDispatchWaiters: [],
replayContentFallbackBlocked: false,
backgroundTasks: new ClaudeBackgroundTaskTracker(),
+ commands: new ClaudeSlashCommandCatalog(),
dispatchSequence: 0,
optionMutationSequence: 0,
options: new Map(),
diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts
index 0f22c175cc6..cb3bb2b72ea 100644
--- a/src/main/claude/claude-structured-real-cli.test.ts
+++ b/src/main/claude/claude-structured-real-cli.test.ts
@@ -1,6 +1,6 @@
import { spawnSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
-import { mkdtemp, rm } from 'node:fs/promises'
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { basename, join, relative } from 'node:path'
import { describe, expect, it } from 'vitest'
@@ -48,13 +48,14 @@ const realClaudeAuthenticated = realClaudeAuthStatus?.loggedIn === true
function realAdapter(
providerSessionId: string,
claudeConfigDir: string,
- events: ClaudeStructuredSessionEvent[] = []
+ events: ClaudeStructuredSessionEvent[] = [],
+ cwd = process.cwd()
): ClaudeStructuredSessionAdapter {
return new ClaudeStructuredSessionAdapter({
resolveLaunch: async () => ({
pathToClaudeCodeExecutable: command,
options: { ...CLAUDE_STRUCTURED_BASE_OPTIONS, sessionId: providerSessionId },
- cwd: process.cwd(),
+ cwd,
claudeConfigDir,
providerSessionId,
resumeLeafUuid: null,
@@ -100,7 +101,13 @@ describe.skipIf(!realClaudeAvailable)('Claude structured real CLI handshake', ()
const providerSessionId = randomUUID()
const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude')
const events: ClaudeStructuredSessionEvent[] = []
- const adapter = realAdapter(providerSessionId, claudeConfigDir, events)
+ const cwd = await mkdtemp(join(tmpdir(), 'orca-command-init-'))
+ await mkdir(join(cwd, '.claude', 'commands'), { recursive: true })
+ await writeFile(
+ join(cwd, '.claude', 'commands', 'orca-init-catalog-proof.md'),
+ '---\ndescription: Initialization catalog proof\n---\nReply with OK.\n'
+ )
+ const adapter = realAdapter(providerSessionId, claudeConfigDir, events, cwd)
try {
const acquisition = await adapter.acquire({
@@ -120,8 +127,17 @@ describe.skipIf(!realClaudeAvailable)('Claude structured real CLI handshake', ()
leafUuid: null
})
expect(observedSubtypes).toContain('hook_started')
+ expect(adapter.readCommands('real-cli-handshake')).toContainEqual({
+ name: 'orca-init-catalog-proof',
+ kind: 'command',
+ kindUnspecified: true
+ })
+ expect(
+ adapter.readCommands('real-cli-handshake')?.some(({ name }) => name === 'help')
+ ).toBe(false)
} finally {
await adapter.closeAll()
+ await rm(cwd, { recursive: true, force: true })
}
},
10_000
diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts
index e8d09bd78d9..56b40b27177 100644
--- a/src/main/claude/claude-structured-session-acquisition.ts
+++ b/src/main/claude/claude-structured-session-acquisition.ts
@@ -109,7 +109,11 @@ export async function acquireClaudeSession({
if (liveSession) {
liveSession.leafUuid = observedLeafUuid
}
- const startsTurn = liveSession ? resolveClaudeReplayWaiter(liveSession, message) : false
+ const startsTurn = liveSession
+ ? resolveClaudeReplayWaiter(liveSession, message, (settlement) =>
+ deps.onDispatchSettledLate?.({ sessionId, ...settlement })
+ )
+ : false
callbacks.deliver(attempt, sessionId, () =>
callbacks.emit(liveSession, input.events, {
type: 'message',
@@ -248,6 +252,7 @@ export async function acquireClaudeSession({
const publication = createClaudeSessionPublication({
connection,
init,
+ initialization,
claudeConfigDir: launch.claudeConfigDir,
leafUuid: observedLeafUuid,
fence: input.fence,
diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts
index c00a588e891..a2f7643dbd5 100644
--- a/src/main/claude/claude-structured-session-adapter.ts
+++ b/src/main/claude/claude-structured-session-adapter.ts
@@ -195,6 +195,9 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
: event.type === 'message'
? (session?.backgroundTasks.observe(event.message, event.startsTurn === true) ?? false)
: false
+ if (event.type === 'message' && session?.commands.observe(event.message)) {
+ session.events?.publish()
+ }
session?.translator?.handle(event)
this.deps.onEvent?.(event)
if (backgroundTasksChanged) {
@@ -261,6 +264,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
const session = this.sessions.get(sessionId)
return session ? backgroundTaskState(session) : undefined
}
+ readCommands: NonNullable = (sessionId) =>
+ this.sessions.get(sessionId)?.commands.commands
answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) =>
answerClaudePrompt(this.session(input.sessionId), input)
setOption: StructuredAgentSessionAdapter['setOption'] = (input) =>
diff --git a/src/main/claude/claude-structured-session-commands.test.ts b/src/main/claude/claude-structured-session-commands.test.ts
new file mode 100644
index 00000000000..1b2fb6bde31
--- /dev/null
+++ b/src/main/claude/claude-structured-session-commands.test.ts
@@ -0,0 +1,103 @@
+import { describe, expect, it, vi } from 'vitest'
+import {
+ adapterFor,
+ fakeClaude,
+ identityFor,
+ tick,
+ PROVIDER_SESSION_ID
+} from './claude-structured-session-test-support'
+
+describe('session command updates', () => {
+ it('publishes changed catalogs exactly once while idle', async () => {
+ const claude = fakeClaude()
+ const changed = vi.fn()
+ const adapter = adapterFor(claude)
+ await adapter.acquire({
+ identity: identityFor(),
+ fence: 7,
+ spawnToken: 'spawn-9',
+ events: {
+ appendItem: vi.fn(),
+ appendTombstone: vi.fn(),
+ publish: changed
+ }
+ })
+ changed.mockClear()
+ expect(adapter.readCommands('session-1')).toBeUndefined()
+ const frame = {
+ type: 'system',
+ subtype: 'commands_changed',
+ session_id: PROVIDER_SESSION_ID,
+ slash_commands: ['plugin:check', 'doctor'],
+ skills: ['plugin:check'],
+ terminal_slash_commands: ['doctor']
+ }
+ claude.connections[0].handlers.onMessage?.(frame)
+ await tick()
+ expect(adapter.readCommands('session-1')).toEqual([{ name: 'plugin:check', kind: 'skill' }])
+ expect(changed).toHaveBeenCalledTimes(1)
+ claude.connections[0].handlers.onMessage?.(frame)
+ await tick()
+ expect(changed).toHaveBeenCalledTimes(1)
+ claude.connections[0].handlers.onMessage?.({ ...frame, slash_commands: [] })
+ await tick()
+ expect(adapter.readCommands('session-1')).toEqual([])
+ expect(changed).toHaveBeenCalledTimes(2)
+ await adapter.closeSession('session-1')
+ })
+})
+
+it.each([
+ { commands: [] },
+ { commands: [{ name: 'project:check', description: 'Project command', argumentHint: '' }] }
+])('seeds the pre-prompt catalog from control initialization: %j', async ({ commands }) => {
+ const claude = fakeClaude({ initProof: 'session-start', initCommands: commands })
+ const adapter = adapterFor(claude)
+ try {
+ await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
+ expect(adapter.readCommands('session-1')).toEqual(
+ commands.map(({ name }) => ({ name, kind: 'command', kindUnspecified: true }))
+ )
+ expect(claude.connections[0].sent).toEqual([])
+ expect(claude.connections[0].calls.map(({ subtype }) => subtype)).toEqual([
+ 'initialize',
+ 'get_settings'
+ ])
+ claude.connections[0].handlers.onMessage?.({
+ type: 'system',
+ subtype: 'init',
+ session_id: PROVIDER_SESSION_ID,
+ slash_commands: ['project:check'],
+ skills: ['project:check']
+ })
+ expect(adapter.readCommands('session-1')).toEqual([{ name: 'project:check', kind: 'skill' }])
+ } finally {
+ await adapter.closeSession('session-1')
+ }
+})
+
+it('keeps a buffered stream catalog newer than the initialization response', async () => {
+ const claude = fakeClaude({ initProof: 'session-start', initCommands: [{ name: 'old' }] })
+ const open = claude.openConnection
+ claude.openConnection = async (...args) => {
+ const connection = await open(...args)
+ const getSettings = connection.getSettings
+ connection.getSettings = async (...settingsArgs) => {
+ args[1]?.onMessage?.({
+ type: 'system',
+ subtype: 'commands_changed',
+ session_id: PROVIDER_SESSION_ID,
+ commands: [{ name: 'fresh' }]
+ })
+ return getSettings(...settingsArgs)
+ }
+ return connection
+ }
+ const adapter = adapterFor(claude)
+ try {
+ await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
+ expect(adapter.readCommands('session-1')?.map(({ name }) => name)).toEqual(['fresh'])
+ } finally {
+ await adapter.closeSession('session-1')
+ }
+})
diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts
index 7f8cc4b5692..e55608ae679 100644
--- a/src/main/claude/claude-structured-session-publication.ts
+++ b/src/main/claude/claude-structured-session-publication.ts
@@ -5,10 +5,12 @@ import type { ClaudePromptRegistry } from './claude-structured-prompt-replies'
import type { ClaudeJournalTranslator } from './claude-structured-journal-translation'
import type { ClaudeSession } from './claude-structured-session-state'
import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker'
+import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog'
export function createClaudeSessionPublication(input: {
connection: ClaudeSession['connection']
init: ClaudeInitObservation
+ initialization?: unknown
claudeConfigDir: string
leafUuid: string | null
fence: number
@@ -52,6 +54,7 @@ export function createClaudeSessionPublication(input: {
retiredDispatchWaiters: [],
replayContentFallbackBlocked: false,
backgroundTasks: new ClaudeBackgroundTaskTracker(),
+ commands: new ClaudeSlashCommandCatalog(input.init.message, input.initialization),
dispatchSequence: 0,
optionMutationSequence: 0,
options: new Map(input.options),
diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts
index 1c0b1862913..441d8f68af0 100644
--- a/src/main/claude/claude-structured-session-state.ts
+++ b/src/main/claude/claude-structured-session-state.ts
@@ -1,4 +1,7 @@
-import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
+import type {
+ AgentJournalItemIdentity,
+ AgentSessionJournalIdentity
+} from '../../shared/agent-session-journal-types'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type {
ClaudeStreamJsonConnection,
@@ -11,6 +14,7 @@ import { cancelProcessAcquisition } from '../../shared/child-process/cancel-proc
import { randomUUID } from 'node:crypto'
import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire'
import type { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker'
+import type { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog'
export type ClaudeAuthDiagnostic = {
apiKeySourceConfigured: boolean
@@ -56,6 +60,12 @@ export type ClaudeStructuredSessionAdapterDeps = {
identity: AgentSessionJournalIdentity
}) => Promise
onEvent?: (event: ClaudeStructuredSessionEvent) => void
+ /** A dispatch whose ack timed out, proven delivered by a later provider replay. */
+ onDispatchSettledLate?: (input: {
+ sessionId: string
+ clientMessageId: string
+ providerIdentity: AgentJournalItemIdentity
+ }) => void
onBackgroundTasksChanged?: (
sessionId: string,
state: AgentSessionBackgroundTaskState | null
@@ -87,6 +97,9 @@ export type ClaudeDispatchWaiter = {
resolve: (uuid: string | null) => void
timer: ReturnType
acceptsResult: boolean
+ /** Carried so a replay that lands after the ack window can settle the journal
+ * submission this dispatch came from, not just the in-memory turn identity. */
+ clientMessageId: string
/** Client uuid echoed by Claude so a replay is tied to its own dispatch. */
sentUuid: string
/** Sequence used to fence a late identity from a newer dispatch. */
@@ -126,6 +139,9 @@ export type ClaudeSession = {
/** Provider uuid of the most recently admitted turn, if one is active. */
activeTurnId?: string
backgroundTasks: ClaudeBackgroundTaskTracker
+ /** The `/` surface the CLI reports for itself; seeded from init, kept current
+ * by later init and `commands_changed` frames. */
+ commands: ClaudeSlashCommandCatalog
/** Monotonic fence advanced when a dispatch starts, including unresolved dispatches. */
dispatchSequence: number
/** Dispatch sequence that admitted activeTurnId. */
diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts
index 903cafae416..15a9fcbbb8f 100644
--- a/src/main/claude/claude-structured-session-test-support.ts
+++ b/src/main/claude/claude-structured-session-test-support.ts
@@ -52,6 +52,7 @@ export function fakeClaude(
initModel?: string
initProof?: 'init' | 'session-start' | 'none'
initAccount?: unknown
+ initCommands?: unknown
exitBeforeInit?: string
settings?: unknown
replayUuid?: string | null
@@ -111,6 +112,7 @@ export function fakeClaude(
}
return {
models: [{ value: 'claude-sonnet', displayName: 'Sonnet' }],
+ ...(options.initCommands === undefined ? {} : { commands: options.initCommands }),
...(options.initAccount === undefined ? {} : { account: options.initAccount })
}
},
diff --git a/src/main/cli/orca-cli-child-path.test.ts b/src/main/cli/orca-cli-child-path.test.ts
new file mode 100644
index 00000000000..727732dd64c
--- /dev/null
+++ b/src/main/cli/orca-cli-child-path.test.ts
@@ -0,0 +1,125 @@
+import { join } from 'node:path'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const shim = vi.hoisted(() => ({ ensureLinuxTerminalOrcaCliShimDir: vi.fn() }))
+vi.mock('./linux-terminal-orca-cli-shim', () => shim)
+
+import { prependOrcaCliDirToChildPath } from './orca-cli-child-path'
+
+const USER_DATA = '/data/orca'
+const RESOURCES = '/app/Resources'
+const SHIM_DIR = join(USER_DATA, 'linux-orca-cli-shim')
+
+beforeEach(() => {
+ shim.ensureLinuxTerminalOrcaCliShimDir.mockReset()
+ shim.ensureLinuxTerminalOrcaCliShimDir.mockReturnValue(SHIM_DIR)
+})
+
+describe('prependOrcaCliDirToChildPath', () => {
+ it('leads packaged Linux PATH with the bare-orca shim dir', () => {
+ // Why this matters at all: the Linux CLI installs as `orca-ide` so it never claims GNOME
+ // Orca's /usr/bin/orca screen reader, so bare `orca` only works through this shim.
+ const env: Record = { PATH: '/usr/local/bin:/usr/bin' }
+ prependOrcaCliDirToChildPath(env, {
+ isPackaged: true,
+ userDataPath: USER_DATA,
+ resourcesPath: RESOURCES,
+ platform: 'linux'
+ })
+ expect(env.PATH).toBe(`${SHIM_DIR}:/usr/local/bin:/usr/bin`)
+ expect(shim.ensureLinuxTerminalOrcaCliShimDir).toHaveBeenCalledWith({
+ userDataPath: USER_DATA
+ })
+ })
+
+ it('promotes an already-present shim dir instead of duplicating it', () => {
+ const env: Record = { PATH: `/usr/bin:${SHIM_DIR}::/bin` }
+ prependOrcaCliDirToChildPath(env, {
+ isPackaged: true,
+ userDataPath: USER_DATA,
+ platform: 'linux'
+ })
+ expect(env.PATH).toBe(`${SHIM_DIR}:/usr/bin:/bin`)
+ })
+
+ it('leaves packaged Linux PATH untouched when no shim could be written', () => {
+ shim.ensureLinuxTerminalOrcaCliShimDir.mockReturnValue(null)
+ const env: Record = { PATH: '/usr/bin' }
+ prependOrcaCliDirToChildPath(env, {
+ isPackaged: true,
+ userDataPath: USER_DATA,
+ platform: 'linux'
+ })
+ expect(env.PATH).toBe('/usr/bin')
+ })
+
+ it('leads packaged macOS PATH with the bundled CLI dir', () => {
+ const env: Record = { PATH: '/usr/bin' }
+ prependOrcaCliDirToChildPath(env, {
+ isPackaged: true,
+ userDataPath: USER_DATA,
+ resourcesPath: RESOURCES,
+ platform: 'darwin'
+ })
+ expect(env.PATH).toBe(`${join(RESOURCES, 'bin')}:/usr/bin`)
+ expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled()
+ })
+
+ it('leads packaged Windows PATH with the bundled CLI dir under the env block spelling', () => {
+ const env: Record = { Path: 'C:\\Windows\\System32' }
+ prependOrcaCliDirToChildPath(env, {
+ isPackaged: true,
+ userDataPath: USER_DATA,
+ resourcesPath: RESOURCES,
+ platform: 'win32'
+ })
+ expect(env.Path).toBe(`${join(RESOURCES, 'bin')};C:\\Windows\\System32`)
+ expect(env.PATH).toBeUndefined()
+ })
+
+ it('leaves a packaged darwin/win32 PATH alone with no resources root', () => {
+ const env: Record = { PATH: '/usr/bin' }
+ prependOrcaCliDirToChildPath(env, {
+ isPackaged: true,
+ userDataPath: USER_DATA,
+ resourcesPath: null,
+ platform: 'darwin'
+ })
+ expect(env.PATH).toBe('/usr/bin')
+ })
+
+ it.each<[NodeJS.Platform, string]>([
+ ['linux', ':'],
+ ['darwin', ':'],
+ ['win32', ';']
+ ])('leads an unpackaged %s PATH with the dev launcher dir', (platform, pathDelimiter) => {
+ const env: Record = { PATH: '/usr/bin' }
+ prependOrcaCliDirToChildPath(env, {
+ isPackaged: false,
+ userDataPath: USER_DATA,
+ resourcesPath: RESOURCES,
+ platform
+ })
+ expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}${pathDelimiter}/usr/bin`)
+ expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled()
+ })
+
+ it('writes no trailing delimiter when nothing was inherited', () => {
+ const env: Record = { PATH: '' }
+ const inheritedPath = process.env.PATH
+ delete process.env.PATH
+ try {
+ prependOrcaCliDirToChildPath(env, {
+ isPackaged: false,
+ userDataPath: USER_DATA,
+ platform: 'linux'
+ })
+ } finally {
+ if (inheritedPath !== undefined) {
+ process.env.PATH = inheritedPath
+ }
+ }
+ // Why: an empty trailing segment resolves as `.` in some shells.
+ expect(env.PATH).toBe(join(USER_DATA, 'cli', 'bin'))
+ })
+})
diff --git a/src/main/cli/orca-cli-child-path.ts b/src/main/cli/orca-cli-child-path.ts
new file mode 100644
index 00000000000..f053e54c3d5
--- /dev/null
+++ b/src/main/cli/orca-cli-child-path.ts
@@ -0,0 +1,63 @@
+/**
+ * The PATH entry through which an Orca-launched child reaches THIS app's own CLI.
+ *
+ * Extracted from `buildPtyHostEnv` so the structured-session lane can apply the identical
+ * treatment. A structured worker has no PTY, but its provider child runs `orca orchestration ...`
+ * exactly like a PTY worker's agent does, and it was inheriting the ambient PATH instead. On
+ * packaged Linux that made bare `orca` resolve to GNOME's /usr/bin/orca screen reader, because
+ * Orca's Linux CLI installs as `orca-ide` to avoid claiming that name (stablyai/orca#7904); on
+ * packaged macOS/Windows it reached this app's bundled CLI only if the user had separately
+ * registered the CLI globally.
+ *
+ * `platform` is a test seam only: production leaves it unset and reads `process.platform`, so
+ * every branch behaves exactly as it did inside `buildPtyHostEnv`.
+ */
+
+import { delimiter, join } from 'node:path'
+import { readInheritedPath } from '../ipc/pty/host-env/path'
+import { resolvePathEnvKey } from '../pty/windows-environment-path'
+import { ensureLinuxTerminalOrcaCliShimDir } from './linux-terminal-orca-cli-shim'
+
+export type OrcaCliChildPathOptions = {
+ isPackaged: boolean
+ userDataPath: string
+ resourcesPath?: string | null
+ /** Test seam — production reads the real platform, which is what every branch below assumes. */
+ platform?: NodeJS.Platform
+}
+
+/** Mutates `env` in place, prepending the directory that makes bare `orca` this app's CLI. */
+export function prependOrcaCliDirToChildPath(
+ env: Record,
+ opts: OrcaCliChildPathOptions
+): void {
+ const platform = opts.platform ?? process.platform
+ // Why: matches node:path's `delimiter` for the running platform, but stays correct when a test
+ // drives a foreign platform through the seam.
+ const pathDelimiter = platform === 'win32' ? ';' : delimiter
+ // Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca.
+ if (!opts.isPackaged) {
+ const devCliBin = join(opts.userDataPath, 'cli', 'bin')
+ const inheritedPath = readInheritedPath(env, platform)
+ // Why: an empty PATH segment resolves as `.` in some shells (commands run from cwd); avoid a trailing delimiter.
+ env[resolvePathEnvKey(env, platform)] = inheritedPath
+ ? `${devCliBin}${pathDelimiter}${inheritedPath}`
+ : devCliBin
+ } else if (platform === 'linux') {
+ // Why: bare-`orca` shim scoped to Orca PTYs — Linux CLI installs as `orca-ide` to avoid shadowing GNOME's /usr/bin/orca screen reader (stablyai/orca#7904).
+ const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath: opts.userDataPath })
+ if (shimDir) {
+ const inheritedEntries = readInheritedPath(env, platform)
+ .split(pathDelimiter)
+ .filter((entry) => entry.length > 0 && entry !== shimDir)
+ env.PATH = [shimDir, ...inheritedEntries].join(pathDelimiter)
+ }
+ } else if (opts.resourcesPath && (platform === 'darwin' || platform === 'win32')) {
+ // Why: global CLI registration is optional, but agents in Orca-managed PTYs must always reach this app's bundled CLI.
+ const bundledCliBin = join(opts.resourcesPath, 'bin')
+ const inheritedPath = readInheritedPath(env, platform)
+ env[resolvePathEnvKey(env, platform)] = inheritedPath
+ ? `${bundledCliBin}${pathDelimiter}${inheritedPath}`
+ : bundledCliBin
+ }
+}
diff --git a/src/main/codex-accounts/runtime-home-settings-test-fixtures.ts b/src/main/codex-accounts/runtime-home-settings-test-fixtures.ts
index 2872ecf15c3..c7be08b3509 100644
--- a/src/main/codex-accounts/runtime-home-settings-test-fixtures.ts
+++ b/src/main/codex-accounts/runtime-home-settings-test-fixtures.ts
@@ -113,6 +113,7 @@ export function createSettings(overrides: TestSettingsOverrides = {}): GlobalSet
opencodeWorkspaceId: '',
minimaxGroupId: '',
minimaxUsageModels: 'general',
+ minimaxEndpoint: 'overseas',
geminiCliOAuthEnabled: false,
agentCmdOverrides: {},
keepComputerAwakeWhileAgentsRun: false,
diff --git a/src/main/codex-accounts/service-reset-credit-test-fixtures.ts b/src/main/codex-accounts/service-reset-credit-test-fixtures.ts
index 4578831985a..d47c967e34c 100644
--- a/src/main/codex-accounts/service-reset-credit-test-fixtures.ts
+++ b/src/main/codex-accounts/service-reset-credit-test-fixtures.ts
@@ -36,6 +36,7 @@ export function createResetRateLimitState(
minimax: null,
grok: null,
minimaxCookieConfigured: false,
+ minimaxApiKeyConfigured: false,
grokAuthConfigured: false,
claudeTarget: { runtime: 'host', wslDistro: null },
codexTarget: target,
diff --git a/src/main/codex-accounts/service-test-harness.ts b/src/main/codex-accounts/service-test-harness.ts
index ed454c7a149..6c0a33135ab 100644
--- a/src/main/codex-accounts/service-test-harness.ts
+++ b/src/main/codex-accounts/service-test-harness.ts
@@ -134,6 +134,7 @@ export function createSettings(overrides: Partial = {}): GlobalS
opencodeWorkspaceId: '',
minimaxGroupId: '',
minimaxUsageModels: 'general',
+ minimaxEndpoint: 'overseas',
geminiCliOAuthEnabled: false,
agentCmdOverrides: {},
keepComputerAwakeWhileAgentsRun: false,
diff --git a/src/main/codex/codex-structured-child-environment.test.ts b/src/main/codex/codex-structured-child-environment.test.ts
index 98e988ec7d5..201efc0b0c5 100644
--- a/src/main/codex/codex-structured-child-environment.test.ts
+++ b/src/main/codex/codex-structured-child-environment.test.ts
@@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest'
import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity'
import { buildCodexStructuredChildEnvironment } from './codex-structured-child-environment'
+import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker'
+import {
+ mintStructuredWorkerHandle,
+ mintStructuredWorkerPaneKey,
+ structuredWorkerIdentities,
+ structuredWorkerProcessIncarnation
+} from '../runtime/structured-worker-identity'
describe('buildCodexStructuredChildEnvironment', () => {
it('keeps shell exports while pinned launch values win', () => {
@@ -14,12 +21,51 @@ describe('buildCodexStructuredChildEnvironment', () => {
resumeThreadId: null,
env: { EXAMPLE_GATEWAY_TOKEN: 'shell-exported', CODEX_HOME: '/shell/home' }
},
- 'spawn-token'
+ 'spawn-token',
+ 'session-not-a-worker'
)
).toEqual({
EXAMPLE_GATEWAY_TOKEN: 'shell-exported',
CODEX_HOME: '/pinned/home',
- [CODEX_SPAWN_TOKEN_ENV]: 'spawn-token'
+ [CODEX_SPAWN_TOKEN_ENV]: 'spawn-token',
+ [ORCA_STRUCTURED_SESSION_ENV]: '1'
})
})
+
+ it('adds the orchestration handle only for a registered structured worker', () => {
+ const launch = {
+ command: 'codex',
+ args: ['app-server'],
+ cwd: '/worktree',
+ codexHome: null,
+ resumeThreadId: null,
+ env: {}
+ }
+ const sessionId = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d'
+ expect(buildCodexStructuredChildEnvironment(launch, 'spawn-token', sessionId)).toEqual({
+ [CODEX_SPAWN_TOKEN_ENV]: 'spawn-token',
+ // No identity yet, so the child carries only the refuse-rather-than-guess marker.
+ [ORCA_STRUCTURED_SESSION_ENV]: '1'
+ })
+
+ const handle = mintStructuredWorkerHandle()
+ structuredWorkerIdentities.register({
+ handle,
+ sessionId,
+ agent: 'codex',
+ paneKey: mintStructuredWorkerPaneKey(sessionId),
+ processIncarnation: structuredWorkerProcessIncarnation(sessionId),
+ worktreeId: 'wt_1',
+ hostScope: { kind: 'local', hostId: 'local' }
+ })
+ try {
+ const env = buildCodexStructuredChildEnvironment(launch, 'spawn-token', sessionId)
+ expect(env.ORCA_TERMINAL_HANDLE).toBe(handle)
+ expect(env.ORCA_CLI_COMMAND).toBe('orca')
+ // A pane key here would leak into hook-emitted agent statuses, which assume a PTY leaf.
+ expect(env.ORCA_PANE_KEY).toBeUndefined()
+ } finally {
+ structuredWorkerIdentities.forget(handle)
+ }
+ })
})
diff --git a/src/main/codex/codex-structured-child-environment.ts b/src/main/codex/codex-structured-child-environment.ts
index 88326eb3fc0..72bf17a1bce 100644
--- a/src/main/codex/codex-structured-child-environment.ts
+++ b/src/main/codex/codex-structured-child-environment.ts
@@ -1,13 +1,19 @@
import type { CodexStructuredLaunch } from './codex-structured-session-state'
import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity'
+import { structuredWorkerChildIdentityEnv } from '../runtime/structured-worker-child-identity-env'
export function buildCodexStructuredChildEnvironment(
launch: CodexStructuredLaunch,
- spawnToken: string
+ spawnToken: string,
+ sessionId: string
): Record {
return {
- ...launch.env,
- ...(launch.codexHome ? { CODEX_HOME: launch.codexHome } : {}),
+ // Only a dispatched structured worker gets the orchestration identity and the Orca CLI on
+ // PATH; an ordinary chat session's env passes through untouched.
+ ...structuredWorkerChildIdentityEnv(sessionId, {
+ ...launch.env,
+ ...(launch.codexHome ? { CODEX_HOME: launch.codexHome } : {})
+ }),
[CODEX_SPAWN_TOKEN_ENV]: spawnToken
}
}
diff --git a/src/main/codex/codex-structured-journal-items.ts b/src/main/codex/codex-structured-journal-items.ts
index 0e4e3f5a900..fd8fbf558a8 100644
--- a/src/main/codex/codex-structured-journal-items.ts
+++ b/src/main/codex/codex-structured-journal-items.ts
@@ -60,7 +60,10 @@ export class CodexJournalItems {
return this.details.get(codexStructuredItemKey(threadId, itemId)) ?? null
}
- handle(event: { threadId: string; method: string; params: unknown }): CodexItemTranslation {
+ handle(
+ event: { threadId: string; method: string; params: unknown },
+ source: 'live' | 'history' = 'live'
+ ): CodexItemTranslation {
const params =
typeof event.params === 'object' && event.params !== null
? (event.params as Record)
@@ -71,6 +74,10 @@ export class CodexJournalItems {
}
const turnId = readCodexTurnId(event.params) ?? this.activeTurn(event.threadId)
const identity = this.identityFor(event.threadId, turnId, item)
+ // Count echoes for stable resume ordinals, but user bubbles come from submissions.
+ if (source === 'live' && item.type === 'userMessage') {
+ return { handled: true, admission: CODEX_JOURNAL_ADMITTED }
+ }
const translated = codexJournalItem(item)
const command = readCodexJournalString(item, 'command')
if (command) {
diff --git a/src/main/codex/codex-structured-journal-translation-frames.ts b/src/main/codex/codex-structured-journal-translation-frames.ts
new file mode 100644
index 00000000000..22dc516b210
--- /dev/null
+++ b/src/main/codex/codex-structured-journal-translation-frames.ts
@@ -0,0 +1,43 @@
+/**
+ * The translator's provider-frame arms.
+ *
+ * Each returns null for a frame it does not own, which is the translator's
+ * signal to keep looking. Split out so the translator reads as routing rather
+ * than as the shape checks each arm performs.
+ */
+
+import type { CodexJournalTranslationAdmission } from './codex-structured-journal-contracts'
+import { settleCodexOversizedNotification } from './codex-structured-journal-settlement'
+import {
+ readCodexJournalRecord,
+ readCodexJournalString
+} from './codex-structured-journal-translation-values'
+
+type OversizedInput = Parameters[0]
+
+/** A notification the transport refused to carry whole: settle whatever it
+ * opened rather than leaving the item mid-flight. */
+export function settleCodexOversizedNotificationFrame(input: {
+ sessionId: string
+ threadId: string
+ kind: string
+ payload: unknown
+ sink: OversizedInput['sink']
+ streams: OversizedInput['streams']
+ activeItems: OversizedInput['activeItems']
+}): CodexJournalTranslationAdmission | null {
+ if (input.kind !== 'frame:oversized-notification') {
+ return null
+ }
+ const method = readCodexJournalString(readCodexJournalRecord(input.payload), 'method')
+ return method
+ ? settleCodexOversizedNotification({
+ sessionId: input.sessionId,
+ threadId: input.threadId,
+ method,
+ sink: input.sink,
+ streams: input.streams,
+ activeItems: input.activeItems
+ })
+ : null
+}
diff --git a/src/main/codex/codex-structured-journal-translation-settlement.test.ts b/src/main/codex/codex-structured-journal-translation-settlement.test.ts
index 5773cf8d6fa..1f2bc480a22 100644
--- a/src/main/codex/codex-structured-journal-translation-settlement.test.ts
+++ b/src/main/codex/codex-structured-journal-translation-settlement.test.ts
@@ -212,7 +212,7 @@ describe('codex journal translation', () => {
expect(translator.handle(notification('turn/completed', { turn: { id: TURN_ID } }))).toEqual({
accepted: true
})
- expect(deferred.state()).toMatchObject({ queuedOperations: 4, backpressured: true })
+ expect(deferred.state()).toMatchObject({ queuedOperations: 5, backpressured: true })
deferred.bind(deferredTarget(bodies, publishes))
await expect(deferred.lifecycleBarrier()).resolves.toEqual({ ok: true })
@@ -225,7 +225,7 @@ describe('codex journal translation', () => {
expect.objectContaining({ kind: 'tool-call', state: 'running' }),
expect.objectContaining({ kind: 'tool-call', state: 'failed' })
])
- expect(publishes).toHaveLength(1)
+ expect(publishes).toHaveLength(2)
})
it('admits terminal session settlement publication across the hard watermark', async () => {
@@ -257,7 +257,7 @@ describe('codex journal translation', () => {
acquisitionGeneration: 'generation-1'
})
).toEqual({ accepted: true })
- expect(deferred.state()).toMatchObject({ queuedOperations: 4, backpressured: true })
+ expect(deferred.state()).toMatchObject({ queuedOperations: 5, backpressured: true })
deferred.bind(deferredTarget(bodies, publishes))
await expect(deferred.lifecycleBarrier()).resolves.toEqual({ ok: true })
@@ -277,7 +277,7 @@ describe('codex journal translation', () => {
}),
{ kind: 'status', text: 'Provider exited: lost child' }
])
- expect(publishes).toHaveLength(1)
+ expect(publishes).toHaveLength(2)
})
it('retries a rejected terminal admission without losing tool, prompt, turn, or session truth', () => {
@@ -652,7 +652,7 @@ describe('codex journal translation', () => {
translator.handle(TURN_STARTED)
translator.handle(
- notification('item/completed', { item: { type: 'userMessage', id: 'item-0', text: 'one' } })
+ notification('item/completed', { item: { type: 'agentMessage', id: 'item-0', text: 'one' } })
)
translator.handle(notification('turn/completed', { turn: { id: TURN_ID } }))
translator.handle(
@@ -662,7 +662,7 @@ describe('codex journal translation', () => {
)
translator.handle(notification('turn/started', { turn: { id: 'turn-2' } }))
translator.handle(
- notification('item/completed', { item: { type: 'userMessage', id: 'item-2', text: 'two' } })
+ notification('item/completed', { item: { type: 'agentMessage', id: 'item-2', text: 'two' } })
)
expect(tap.rows.map((row) => row.key)).toEqual([
@@ -679,7 +679,7 @@ describe('codex journal translation', () => {
translator.handle(
notification('item/completed', {
turnId: 'turn-9',
- item: { type: 'userMessage', id: 'item-0', text: 'late' }
+ item: { type: 'agentMessage', id: 'item-0', text: 'late' }
})
)
diff --git a/src/main/codex/codex-structured-journal-translation-streams.test.ts b/src/main/codex/codex-structured-journal-translation-streams.test.ts
index a9bc79b71c4..86eb94fef07 100644
--- a/src/main/codex/codex-structured-journal-translation-streams.test.ts
+++ b/src/main/codex/codex-structured-journal-translation-streams.test.ts
@@ -157,7 +157,7 @@ describe('codex journal translation', () => {
translator.handle(TURN_STARTED)
translator.handle(
- notification('item/completed', { item: { type: 'userMessage', id: 'item-0', text: 'hi' } })
+ notification('item/completed', { item: { type: 'agentMessage', id: 'item-0', text: 'hi' } })
)
expect(tap.publishes()).toBe(1)
@@ -520,7 +520,7 @@ describe('codex journal translation', () => {
expect(timeline).toEqual([])
})
- it('projects only user and assistant content for a complete turn with hooks', () => {
+ it('projects assistant content without provider user echoes for a complete turn with hooks', () => {
const { translator, tap } = translatorWith()
translator.handle(notification('thread/started', { thread: { id: THREAD_ID } }))
@@ -552,7 +552,6 @@ describe('codex journal translation', () => {
}))
)
expect(timeline.map(({ role, blocks }) => ({ role, blocks }))).toEqual([
- { role: 'user', blocks: [{ type: 'text', text: 'hi' }] },
{ role: 'assistant', blocks: [{ type: 'text', text: 'hello' }] }
])
})
diff --git a/src/main/codex/codex-structured-journal-translation-turns.ts b/src/main/codex/codex-structured-journal-translation-turns.ts
index 06bb28f85f9..7313946a53e 100644
--- a/src/main/codex/codex-structured-journal-translation-turns.ts
+++ b/src/main/codex/codex-structured-journal-translation-turns.ts
@@ -56,9 +56,16 @@ export function publishCodexTurnLifecycle(input: {
return admission
}
}
- if (input.sink.tryPublish) {
- return input.sink.tryPublish({ lifecycle: true })
+ // Preserve first-work evidence when completion arrives before the journal drains.
+ const publishOptions = {
+ lifecycle: true,
+ ...(input.state === 'running'
+ ? { coalescingKey: `turn-start:${input.sessionId}:${input.turnId}` }
+ : {})
}
- input.sink.publish({ lifecycle: true })
+ if (input.sink.tryPublish) {
+ return input.sink.tryPublish(publishOptions)
+ }
+ input.sink.publish(publishOptions)
return ADMITTED
}
diff --git a/src/main/codex/codex-structured-journal-translation.test.ts b/src/main/codex/codex-structured-journal-translation.test.ts
index e88bd1d5a65..0a791afa77e 100644
--- a/src/main/codex/codex-structured-journal-translation.test.ts
+++ b/src/main/codex/codex-structured-journal-translation.test.ts
@@ -302,7 +302,7 @@ describe('codex journal translation', () => {
).toBe('idle')
})
- it('journals a user turn and the assistant answer under durable codex keys', () => {
+ it('counts a user echo without rendering it and preserves the assistant ordinal', () => {
const { translator, tap } = translatorWith()
translator.handle(TURN_STARTED)
@@ -317,17 +317,37 @@ describe('codex journal translation', () => {
})
)
- expect(tap.rows.map((row) => row.key)).toEqual([
- 'codex:thread-abc:turn-1:0',
- 'codex:thread-abc:turn-1:1'
- ])
- expect(tap.rows[1]?.body).toEqual({
+ expect(tap.rows.map((row) => row.key)).toEqual(['codex:thread-abc:turn-1:1'])
+ expect(tap.rows[0]?.body).toEqual({
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: 'hello' }]
})
})
+ it('suppresses both echo lifecycle frames, including skill and unknown parts', () => {
+ const { translator, tap } = translatorWith()
+ translator.handle(TURN_STARTED)
+ const item = {
+ type: 'userMessage',
+ id: 'echo',
+ content: [
+ { type: 'text', text: 'Expanded instructions' },
+ { type: 'skill', name: 'example', path: '/tmp/SKILL.md' },
+ { type: 'future_context', text: 'More context' }
+ ]
+ }
+ translator.handle(notification('item/started', { item }))
+ translator.handle(notification('item/completed', { item }))
+ expect(tap.rows).toEqual([])
+ translator.handle(
+ notification('item/completed', {
+ item: { type: 'agentMessage', id: 'answer', text: 'Done' }
+ })
+ )
+ expect(tap.rows.map((row) => row.key)).toEqual(['codex:thread-abc:turn-1:1'])
+ })
+
it('folds streamed deltas into one snapshot row on the same key the item started under', () => {
const { translator, tap, window } = translatorWith()
diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts
index c8ecc996742..85f3bf76e46 100644
--- a/src/main/codex/codex-structured-journal-translation.ts
+++ b/src/main/codex/codex-structured-journal-translation.ts
@@ -1,4 +1,8 @@
-import { CODEX_TOKEN_USAGE_METHOD } from './codex-subagent-activity'
+import { createCodexProviderActivityReader } from '../native-chat/agent-session-wire/provider-frame-activity'
+import {
+ CODEX_TOKEN_USAGE_METHOD,
+ readCodexNotificationThreadItem
+} from './codex-subagent-activity'
import { CodexSubagentRoster } from './codex-subagent-roster'
import { readCodexThreadItem } from './codex-structured-item-translation'
import { CodexJournalGenericFrames } from './codex-structured-journal-generic-frames'
@@ -12,17 +16,14 @@ import {
} from './codex-structured-journal-contracts'
import {
settleCodexJournalSession,
- settleCodexJournalTurn,
- settleCodexOversizedNotification
+ settleCodexJournalTurn
} from './codex-structured-journal-settlement'
+import { settleCodexOversizedNotificationFrame } from './codex-structured-journal-translation-frames'
import { restoreCodexJournalThread } from './codex-structured-journal-translation-restore'
import { CodexJournalActiveTurns } from './codex-structured-journal-translation-turn-state'
import { publishCodexTurnLifecycle } from './codex-structured-journal-translation-turns'
-import {
- readCodexJournalRecord,
- readCodexJournalString
-} from './codex-structured-journal-translation-values'
import { readCodexTurnId } from './codex-structured-thread-facts'
+import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter'
export type {
CodexJournalTranslationAdmission,
@@ -63,22 +64,44 @@ export function createCodexJournalTranslator(
})
const flushStreams = (): CodexJournalTranslationAdmission =>
items.streams.flush() ? CODEX_JOURNAL_ADMITTED : { accepted: false, reason: 'backpressure' }
+ let readActivity = createCodexProviderActivityReader()
+ const publishActivity = (
+ event: Extract,
+ admission: CodexJournalTranslationAdmission
+ ): CodexJournalTranslationAdmission => {
+ if (!admission.accepted || event.threadId !== (deps.primaryThreadId?.() ?? null)) {
+ return admission
+ }
+ const turnId = readCodexTurnId(event.params) ?? activeTurns.current(event.threadId)
+ if (!turnId) {
+ return admission
+ }
+ const text = readActivity(event.method, event.params)
+ if (text !== undefined) {
+ deps.sink.setActivity?.(text ? { turnId, text } : null)
+ }
+ return admission
+ }
return {
- restoreThread: (threadId, thread) =>
- restoreCodexJournalThread({
+ restoreThread: (threadId, thread) => {
+ if (threadId === (deps.primaryThreadId?.() ?? null)) {
+ readActivity = createCodexProviderActivityReader()
+ }
+ return restoreCodexJournalThread({
threadId,
thread,
currentTurnIds: activeTurns.byThread,
ordinals: items.ordinals,
handleItem: (event) => {
- const translated = items.handle(event)
+ const translated = items.handle(event, 'history')
return translated.handled
? translated.admission
: { accepted: false, reason: 'untranslated' }
},
flush: items.streams.flush
- }),
+ })
+ },
handle: (event) => {
if (event.type === 'ended') {
const streamAdmission = flushStreams()
@@ -107,6 +130,8 @@ export function createCodexJournalTranslator(
if (!sweep.accepted) {
return sweep
}
+ readActivity = createCodexProviderActivityReader()
+ deps.sink.setActivity?.(null)
items.activeItems.clear()
prompts.pending.clear()
activeTurns.clear()
@@ -115,7 +140,7 @@ export function createCodexJournalTranslator(
if (event.type === 'notification') {
const streamResult = items.streams.handle(event.threadId, event.method, event.params)
if (streamResult.handled) {
- return streamResult.admission
+ return publishActivity(event, streamResult.admission)
}
}
const streamAdmission = flushStreams()
@@ -155,23 +180,29 @@ export function createCodexJournalTranslator(
}
}
if (event.method === 'item/started' || event.method === 'item/completed') {
- const subagentAdmission = handleSubagentItem(event)
- if (subagentAdmission) {
- return subagentAdmission
+ const subagentItem = readCodexNotificationThreadItem(event.params, readCodexThreadItem)
+ if (subagentItem) {
+ return subagents.handleItem({
+ threadId: event.threadId,
+ turnId: readCodexTurnId(event.params) ?? activeTurns.current(event.threadId),
+ item: subagentItem
+ })
}
const translated = items.handle(event)
- return translated.handled
- ? translated.admission
- : genericFrames.appendUnhandled(
- `notification:${event.method}`,
- event.params,
- event.threadId
- )
+ return publishActivity(
+ event,
+ translated.handled
+ ? translated.admission
+ : genericFrames.appendUnhandled(
+ `notification:${event.method}`,
+ event.params,
+ event.threadId
+ )
+ )
}
- return genericFrames.appendUnhandled(
- `notification:${event.method}`,
- event.params,
- event.threadId
+ return publishActivity(
+ event,
+ genericFrames.appendUnhandled(`notification:${event.method}`, event.params, event.threadId)
)
},
resolvePrompt: (journalItemId) => prompts.resolve(journalItemId),
@@ -189,45 +220,18 @@ export function createCodexJournalTranslator(
}
/** Routes a `subAgentActivity` item to the roster; null when it is not one. */
- function handleSubagentItem(event: {
- threadId: string
- params: unknown
- }): CodexJournalTranslationAdmission | null {
- const params =
- typeof event.params === 'object' && event.params !== null
- ? (event.params as Record)
- : {}
- const item = readCodexThreadItem(params.item)
- if (!item) {
- return null
- }
- return subagents.handleItem({
- threadId: event.threadId,
- turnId: readCodexTurnId(event.params) ?? activeTurns.current(event.threadId),
- item
- })
- }
-
function settleOversizedNotification(event: {
sessionId: string
threadId: string
kind: string
payload: unknown
}): CodexJournalTranslationAdmission | null {
- if (event.kind !== 'frame:oversized-notification') {
- return null
- }
- const method = readCodexJournalString(readCodexJournalRecord(event.payload), 'method')
- return method
- ? settleCodexOversizedNotification({
- sessionId: event.sessionId,
- threadId: event.threadId,
- method,
- sink: deps.sink,
- streams: items.streams,
- activeItems: items.activeItems
- })
- : null
+ return settleCodexOversizedNotificationFrame({
+ ...event,
+ sink: deps.sink,
+ streams: items.streams,
+ activeItems: items.activeItems
+ })
}
function startTurn(event: {
@@ -252,6 +256,10 @@ export function createCodexJournalTranslator(
})
if (admission.accepted) {
activeTurns.remember(event.threadId, turnId)
+ if (event.threadId === (deps.primaryThreadId?.() ?? null)) {
+ readActivity = createCodexProviderActivityReader()
+ deps.sink.setActivity?.(null)
+ }
}
return admission
}
@@ -286,6 +294,10 @@ export function createCodexJournalTranslator(
if (admission.accepted) {
items.ordinals.forgetTurn(event.threadId, turnId)
activeTurns.forget(event.threadId, turnId)
+ if (event.threadId === (deps.primaryThreadId?.() ?? null)) {
+ readActivity = createCodexProviderActivityReader()
+ deps.sink.setActivity?.(null)
+ }
}
return admission
}
diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts
index 04a48a6250e..78855842332 100644
--- a/src/main/codex/codex-structured-session-acquire.ts
+++ b/src/main/codex/codex-structured-session-acquire.ts
@@ -106,7 +106,7 @@ export async function acquireCodexStructuredSession(input: {
command: launch.command,
args: launch.args,
cwd: launch.cwd,
- env: buildCodexStructuredChildEnvironment(launch, acquireInput.spawnToken)
+ env: buildCodexStructuredChildEnvironment(launch, acquireInput.spawnToken, sessionId)
},
{
onNotification: (method, params) =>
diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts
index 5e218c1f31f..32492762121 100644
--- a/src/main/codex/codex-structured-session-adapter.test.ts
+++ b/src/main/codex/codex-structured-session-adapter.test.ts
@@ -12,6 +12,7 @@ import type {
} from './codex-app-server-connection'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity'
+import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker'
import { encodeCodexQuestionOptionId } from './codex-structured-prompt-replies'
import {
CodexStructuredSessionAdapter,
@@ -150,7 +151,8 @@ describe('CodexStructuredSessionAdapter.acquire', () => {
expect(codex.connections[0].launch.env).toEqual({
[CODEX_SPAWN_TOKEN_ENV]: 'spawn-9',
- CODEX_HOME: '/codex/home'
+ CODEX_HOME: '/codex/home',
+ [ORCA_STRUCTURED_SESSION_ENV]: '1'
})
expect(codex.connections[0].launch.cwd).toBe('/work/repo')
expect(codex.connections[0].calls[0]).toEqual({
@@ -285,7 +287,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => {
})
codex.connections[0].handlers.onNotification?.('item/completed', {
- item: { type: 'userMessage', id: 'message-1', text: 'hello' }
+ item: { type: 'agentMessage', id: 'message-1', text: 'hello' }
})
await vi.waitFor(() => {
diff --git a/src/main/codex/codex-subagent-activity.ts b/src/main/codex/codex-subagent-activity.ts
index 29e9211db01..55f39f41627 100644
--- a/src/main/codex/codex-subagent-activity.ts
+++ b/src/main/codex/codex-subagent-activity.ts
@@ -123,3 +123,18 @@ export function readCodexThreadTokenTotal(params: unknown): CodexThreadTokenTota
? { threadId, totalTokens: total }
: null
}
+
+/** Pull the `subAgentActivity` item out of a raw notification payload.
+ *
+ * Lives beside the readers rather than in the translator: the translator's job
+ * is routing, and this is the shape check that decides whether a frame is one
+ * of ours at all. Returns null for anything that is not a thread item, which is
+ * the translator's signal to keep looking. */
+export function readCodexNotificationThreadItem(
+ params: unknown,
+ read: (value: unknown) => CodexThreadItem | null
+): CodexThreadItem | null {
+ const record =
+ typeof params === 'object' && params !== null ? (params as Record) : {}
+ return read(record.item)
+}
diff --git a/src/main/daemon/terminal-host-non-agent-foreground.test.ts b/src/main/daemon/terminal-host-non-agent-foreground.test.ts
new file mode 100644
index 00000000000..4c50699ff2a
--- /dev/null
+++ b/src/main/daemon/terminal-host-non-agent-foreground.test.ts
@@ -0,0 +1,92 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { ProcessTableRow } from '../../shared/process-table-snapshot'
+import type * as SnapshotReader from '../../shared/process-table-snapshot-reader'
+import { inspectTerminalHostProcess } from './terminal-host-process-inspection'
+import type { Session } from './session'
+
+const { readSnapshot } = vi.hoisted(() => ({ readSnapshot: vi.fn() }))
+vi.mock('../../shared/process-table-snapshot-reader', async (importOriginal) => ({
+ ...(await importOriginal()),
+ getStrictProcessTableSnapshotWithAge: readSnapshot
+}))
+
+function table(command: string | null): ProcessTableRow[] {
+ const foregroundPgid = command === null ? 100 : 101
+ const shell: ProcessTableRow = {
+ pid: 100,
+ ppid: 1,
+ pgid: 100,
+ tpgid: foregroundPgid,
+ tty: 'pts/1',
+ startTime: 'shell-start',
+ stat: command === null ? 'Ss+' : 'Ss',
+ command: '/bin/bash'
+ }
+ return command === null
+ ? [shell]
+ : [
+ shell,
+ {
+ ...shell,
+ pid: 101,
+ ppid: 100,
+ pgid: 101,
+ stat: 'S+',
+ startTime: 'command-start',
+ command
+ }
+ ]
+}
+
+async function inspect(rawName: string, command: string | null) {
+ readSnapshot.mockResolvedValue({ rows: table(command), capturedAgeMs: 0 })
+ return inspectTerminalHostProcess({
+ sessionId: 'busy-tab',
+ session: {
+ pid: 100,
+ incarnationId: 'incarnation-1',
+ isAlive: true,
+ getForegroundProcess: () => rawName
+ } as unknown as Session,
+ authorityGeneration: 'generation-1',
+ nextObservationEpoch: () => 1
+ })
+}
+
+afterEach(() => {
+ vi.restoreAllMocks()
+ readSnapshot.mockClear()
+})
+
+describe.each(['linux', 'darwin'] as const)('daemon ordinary foreground on %s', (platform) => {
+ it.each(['sleep', 'vim', 'node'])(
+ 'retains the running %s name alongside agent-only evidence',
+ async (name) => {
+ vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
+ const result = await inspect(name, `${name} 300`)
+ expect(result).toMatchObject({
+ foregroundProcess: name,
+ hasChildProcesses: true,
+ foregroundProcessEvidence: { verdict: 'live', processName: null }
+ })
+ expect(readSnapshot).toHaveBeenCalledTimes(1)
+ }
+ )
+
+ it('still clears a stale recognized agent after its process exits', async () => {
+ vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
+ expect(await inspect('claude', null)).toMatchObject({
+ foregroundProcess: null,
+ foregroundProcessEvidence: { verdict: 'live', processName: null }
+ })
+ })
+
+ it('still reports no foreground command for an idle shell', async () => {
+ vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
+ expect(await inspect('bash', null)).toMatchObject({
+ foregroundProcess: null,
+ hasChildProcesses: false,
+ foregroundProcessEvidence: { verdict: 'live', processName: null }
+ })
+ })
+})
diff --git a/src/main/daemon/terminal-host-process-inspection.ts b/src/main/daemon/terminal-host-process-inspection.ts
index 6810687631e..2f9fb1491d9 100644
--- a/src/main/daemon/terminal-host-process-inspection.ts
+++ b/src/main/daemon/terminal-host-process-inspection.ts
@@ -1,4 +1,5 @@
import { isShellProcess } from '../../shared/agent-detection'
+import { recognizeAgentProcess } from '../../shared/agent-process-recognition'
import type { RemoteForegroundEvidence } from '../../shared/foreground-process-evidence'
import { getCheapProcessTableSnapshot } from '../../shared/cheap-process-table-snapshot-reader'
import { getStrictProcessTableSnapshotWithAge } from '../../shared/process-table-snapshot-reader'
@@ -100,9 +101,16 @@ export async function inspectTerminalHostProcess(args: {
clearSteadyStateAnchor(session)
}
}
+ const nonShellForeground = foregroundProcess !== null && !isShellProcess(foregroundProcess)
+ // Evidence names recognized agents only, so its null must not erase an ordinary command (#18078).
+ const ordinaryForeground =
+ nonShellForeground && !recognizeAgentProcess(foregroundProcess) ? foregroundProcess : null
return {
- foregroundProcess: evidence.verdict === 'live' ? evidence.processName : foregroundProcess,
- hasChildProcesses: foregroundProcess !== null && !isShellProcess(foregroundProcess),
+ foregroundProcess:
+ evidence.verdict === 'live'
+ ? (evidence.processName ?? ordinaryForeground)
+ : foregroundProcess,
+ hasChildProcesses: nonShellForeground,
foregroundProcessEvidence: evidence
}
}
diff --git a/src/main/git/command-runner/exec-file-capture.ts b/src/main/git/command-runner/exec-file-capture.ts
index e9ae815ff34..f343246571d 100644
--- a/src/main/git/command-runner/exec-file-capture.ts
+++ b/src/main/git/command-runner/exec-file-capture.ts
@@ -15,6 +15,8 @@ type ExecFileCaptureOptions = Omit & {
onChildTerminated?: () => void
admissionTier?: GitAdmissionTier
createTimeoutError?: () => Error
+ /** Called once when the deadline — not an abort — is what ended the process. */
+ onDeadlineKill?: () => void
}
const GIT_TERMINATION_BARRIER_FALLBACK_TIMEOUT_MS = 2_147_000_000
@@ -54,6 +56,9 @@ export async function execFileCaptureToTermination(
) {
return { stdout, stderr }
}
+ if (result.timedOut && !options.signal?.aborted) {
+ options.onDeadlineKill?.()
+ }
const error = result.timedOut
? (options.createTimeoutError?.() ?? new Error(`${command} timed out.`))
: new Error(
diff --git a/src/main/git/command-runner/gh-exec-file.ts b/src/main/git/command-runner/gh-exec-file.ts
index e8308a8e4b4..9a92f1d59de 100644
--- a/src/main/git/command-runner/gh-exec-file.ts
+++ b/src/main/git/command-runner/gh-exec-file.ts
@@ -20,6 +20,7 @@ import {
resolveHostGitHubCli
} from './github-cli-host-fallback'
import { execFileCaptureToTermination } from './exec-file-capture'
+import { logHostedCliDeadlineKill } from './hosted-cli-deadline-log'
import type { GitExecOptions } from './git-exec-options'
import { argsLookIdempotent } from './gh-idempotency'
import { applyGhHostToArgs, explicitGhHostname, explicitGhRepoHostname } from './gh-host-args'
@@ -109,6 +110,7 @@ export async function ghExecFileAsync(
// Why: scope by runtime and host so unrelated github.com, GHES, and WSL quotas cannot block each other.
const rateLimitBucket = classifyGhRateLimitBucket(args)
const rateLimitProbe = isGhRateLimitProbe(args)
+ const timeoutMs = options.timeout ?? defaultGhExecTimeoutMs(options.env)
assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe)
let lastError: unknown
let attemptedHostFallback = false
@@ -128,9 +130,10 @@ export async function ghExecFileAsync(
encoding: (options.encoding ?? 'utf-8') as BufferEncoding,
maxBuffer: options.maxBuffer,
// Why: bound gh so one stuck child fails visibly instead of wedging the IPC lane.
- timeout: options.timeout ?? defaultGhExecTimeoutMs(options.env),
+ timeout: timeoutMs,
env: nonInteractiveGhEnv(options.env),
- signal: options.signal
+ signal: options.signal,
+ onDeadlineKill: () => logHostedCliDeadlineKill('gh', resolved.binary, args, timeoutMs)
},
resolved.termination
)
diff --git a/src/main/git/command-runner/glab-exec-file.ts b/src/main/git/command-runner/glab-exec-file.ts
index 3257dd9e818..37aad697b95 100644
--- a/src/main/git/command-runner/glab-exec-file.ts
+++ b/src/main/git/command-runner/glab-exec-file.ts
@@ -3,6 +3,7 @@ import { extractExecError, parseRetryAfterMs } from '../exec-error'
import { resolveCommand, resolveDefaultWslCli } from './wsl-command-resolution'
import { isHostCommandMissing } from './github-cli-host-fallback'
import { execFileCaptureToTermination } from './exec-file-capture'
+import { logHostedCliDeadlineKill } from './hosted-cli-deadline-log'
import type { GitExecOptions } from './git-exec-options'
import { argsLookIdempotent } from './gh-idempotency'
import {
@@ -59,6 +60,7 @@ export async function glabExecFileAsync(
): Promise<{ stdout: string; stderr: string }> {
;({ args, options } = redirectPortedHostnameToEnv(args, options))
let resolved = resolveCommand('glab', args, options.cwd, options.wslDistro)
+ const timeoutMs = options.timeout ?? DEFAULT_GLAB_EXEC_TIMEOUT_MS
let lastError: unknown
let attemptedDefaultWslFallback = false
for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) {
@@ -72,9 +74,10 @@ export async function glabExecFileAsync(
cwd: resolved.cwd,
encoding: (options.encoding ?? 'utf-8') as BufferEncoding,
maxBuffer: options.maxBuffer,
- timeout: options.timeout ?? DEFAULT_GLAB_EXEC_TIMEOUT_MS,
+ timeout: timeoutMs,
env: options.env,
- signal: options.signal
+ signal: options.signal,
+ onDeadlineKill: () => logHostedCliDeadlineKill('glab', resolved.binary, args, timeoutMs)
},
resolved.termination
)
diff --git a/src/main/git/command-runner/hosted-cli-deadline-log.test.ts b/src/main/git/command-runner/hosted-cli-deadline-log.test.ts
new file mode 100644
index 00000000000..06ad0e6261f
--- /dev/null
+++ b/src/main/git/command-runner/hosted-cli-deadline-log.test.ts
@@ -0,0 +1,94 @@
+import { EventEmitter } from 'node:events'
+import type { ChildProcess } from 'node:child_process'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() }))
+
+vi.mock('node:child_process', async (importOriginal) => ({
+ ...(await importOriginal()),
+ spawn: spawnMock
+}))
+
+import { ghExecFileAsync } from './gh-exec-file'
+import { logHostedCliDeadlineKill } from './hosted-cli-deadline-log'
+
+function mockChild(pid = 4321): ChildProcess {
+ const child = new EventEmitter() as EventEmitter & Record
+ child.pid = pid
+ child.kill = vi.fn(() => true)
+ child.stdin = Object.assign(new EventEmitter(), { end: vi.fn() })
+ child.stdout = new EventEmitter()
+ child.stderr = new EventEmitter()
+ return child as unknown as ChildProcess
+}
+
+/**
+ * #18234 took four rounds of strace/perf/proc spelunking from the reporter
+ * because a deadline kill produced no evidence at all. The resolved path is the
+ * fact that names a self-recursive wrapper.
+ */
+describe('hosted CLI deadline logging', () => {
+ let warn: ReturnType
+
+ beforeEach(() => {
+ vi.useFakeTimers()
+ spawnMock.mockReset()
+ warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ vi.spyOn(process, 'kill').mockImplementation((() => true) as unknown as typeof process.kill)
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.restoreAllMocks()
+ })
+
+ it('names the CLI, the deadline and the resolved path, and never the argv values', () => {
+ logHostedCliDeadlineKill(
+ 'gh',
+ '/home/user/.local/bin/gh',
+ ['api', '-H', 'Authorization: token ghp_secret'],
+ 15_000
+ )
+
+ const line = warn.mock.calls[0][0] as string
+ expect(line).toContain('[gh]')
+ expect(line).toContain('15000ms')
+ expect(line).toContain('/home/user/.local/bin/gh')
+ expect(line).toContain('"api"')
+ expect(line).toContain('(3 args)')
+ expect(line).not.toContain('ghp_secret')
+ expect(line).not.toContain('Authorization')
+ })
+
+ it('logs once when gh is killed at its deadline', async () => {
+ spawnMock.mockReturnValue(mockChild())
+
+ const rejection = expect(
+ ghExecFileAsync(['api', '--include', 'user/starred/stablyai/orca'], { timeout: 15_000 })
+ ).rejects.toThrow('timed out')
+ await vi.advanceTimersByTimeAsync(15_000)
+ await vi.advanceTimersByTimeAsync(15_000)
+ await rejection
+
+ const deadlineLines = warn.mock.calls.filter((call) => String(call[0]).startsWith('[gh]'))
+ expect(deadlineLines).toHaveLength(1)
+ expect(String(deadlineLines[0][0])).toContain('wrapper script')
+ })
+
+ it('stays quiet when the caller aborted rather than the deadline firing', async () => {
+ const controller = new AbortController()
+ spawnMock.mockReturnValue(mockChild())
+
+ const rejection = expect(
+ ghExecFileAsync(['api', '--include', 'user/starred/stablyai/orca'], {
+ timeout: 15_000,
+ signal: controller.signal
+ })
+ ).rejects.toThrow()
+ controller.abort()
+ await vi.advanceTimersByTimeAsync(15_000)
+ await rejection
+
+ expect(warn.mock.calls.filter((call) => String(call[0]).startsWith('[gh]'))).toHaveLength(0)
+ })
+})
diff --git a/src/main/git/command-runner/hosted-cli-deadline-log.ts b/src/main/git/command-runner/hosted-cli-deadline-log.ts
new file mode 100644
index 00000000000..3a8be660d14
--- /dev/null
+++ b/src/main/git/command-runner/hosted-cli-deadline-log.ts
@@ -0,0 +1,26 @@
+/**
+ * One log line when `gh`/`glab` is killed at its deadline without answering.
+ *
+ * Why this exists: the deadline kill was completely silent. In #18234 a user's
+ * `~/.local/bin/gh` wrapper (`exec mise x gh -- gh "$@"`) re-execed itself in
+ * place at 100% CPU on every invocation, and the only evidence Orca produced was
+ * that GitHub features quietly did nothing. Diagnosing it took the reporter four
+ * rounds of `strace`, `perf` and `/proc` spelunking. The resolved path below is
+ * the single most useful fact — it names the wrapper.
+ *
+ * Why not the full argv: `gh api` carries `-H Authorization: …` and `--field`
+ * bodies, so only the subcommand and an argument count are safe to print.
+ */
+export function logHostedCliDeadlineKill(
+ cli: string,
+ resolvedBinary: string,
+ args: readonly string[],
+ timeoutMs: number
+): void {
+ const subcommand = args[0] ?? '(none)'
+ console.warn(
+ `[${cli}] killed at its ${timeoutMs}ms deadline without answering — ` +
+ `subcommand "${subcommand}" (${args.length} args), resolved to "${resolvedBinary}". ` +
+ `If that path is a wrapper script, check that it resolves the real ${cli} binary rather than itself.`
+ )
+}
diff --git a/src/main/global-fetch-call-site-audit.test.ts b/src/main/global-fetch-call-site-audit.test.ts
index e4c0539fbfb..7b4151a0293 100644
--- a/src/main/global-fetch-call-site-audit.test.ts
+++ b/src/main/global-fetch-call-site-audit.test.ts
@@ -23,6 +23,7 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map([
['main/orca-profiles/profile-cloud-client.ts', 1],
['main/orca-profiles/profile-cloud-org-members-client.ts', 1],
['main/rate-limits/codex-fetcher.ts', 3],
+ ['main/runtime/push/push-gateway-client.ts', 1],
['main/runtime/relay/relay-http-client.ts', 2],
['main/runtime/relay/relay-region-preference.ts', 3],
['main/source-control/hosted-review-api-request.ts', 1],
diff --git a/src/main/ipc/minimax-credentials.test.ts b/src/main/ipc/minimax-credentials.test.ts
index 77e6f592de0..242ee2217bc 100644
--- a/src/main/ipc/minimax-credentials.test.ts
+++ b/src/main/ipc/minimax-credentials.test.ts
@@ -16,6 +16,9 @@ const saveMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn())
const clearMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn())
const hasMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn(() => false))
const clearMiniMaxSessionCookieJarMock = vi.hoisted(() => vi.fn(() => Promise.resolve()))
+const saveMiniMaxApiKeyMock = vi.hoisted(() => vi.fn())
+const clearMiniMaxApiKeyMock = vi.hoisted(() => vi.fn())
+const hasMiniMaxApiKeyMock = vi.hoisted(() => vi.fn(() => false))
vi.mock('../minimax/minimax-cookie-store', () => ({
saveMiniMaxSessionCookie: saveMiniMaxSessionCookieMock,
@@ -23,6 +26,12 @@ vi.mock('../minimax/minimax-cookie-store', () => ({
hasMiniMaxSessionCookie: hasMiniMaxSessionCookieMock
}))
+vi.mock('../minimax/minimax-api-key-store', () => ({
+ saveMiniMaxApiKey: saveMiniMaxApiKeyMock,
+ clearMiniMaxApiKey: clearMiniMaxApiKeyMock,
+ hasMiniMaxApiKey: hasMiniMaxApiKeyMock
+}))
+
vi.mock('../rate-limits/minimax-request-context', () => ({
clearMiniMaxSessionCookieJar: clearMiniMaxSessionCookieJarMock
}))
@@ -62,35 +71,65 @@ describe('registerMiniMaxCredentialsHandlers', () => {
clearMiniMaxSessionCookieJarMock.mockResolvedValue(undefined)
hasMiniMaxSessionCookieMock.mockReset()
hasMiniMaxSessionCookieMock.mockReturnValue(false)
+ saveMiniMaxApiKeyMock.mockReset()
+ clearMiniMaxApiKeyMock.mockReset()
+ hasMiniMaxApiKeyMock.mockReset()
+ hasMiniMaxApiKeyMock.mockReturnValue(false)
})
afterEach(() => {
vi.restoreAllMocks()
})
- it('registers the three MiniMax credential channels', () => {
+ it('registers all five MiniMax credential channels', () => {
registerMiniMaxCredentialsHandlers(null)
expect(ipcState.handleHandlers.has('minimaxCredentials:getStatus')).toBe(true)
expect(ipcState.handleHandlers.has('minimaxCredentials:saveCookie')).toBe(true)
expect(ipcState.handleHandlers.has('minimaxCredentials:clearCookie')).toBe(true)
+ expect(ipcState.handleHandlers.has('minimaxCredentials:saveApiKey')).toBe(true)
+ expect(ipcState.handleHandlers.has('minimaxCredentials:clearApiKey')).toBe(true)
})
it('returns the configured state on getStatus from the cookie store', async () => {
hasMiniMaxSessionCookieMock.mockReturnValue(true)
registerMiniMaxCredentialsHandlers(null)
- const status = await invoke<{ configured: boolean }>('minimaxCredentials:getStatus')
- expect(status).toEqual({ configured: true })
+ const status = await invoke<{
+ configured: boolean
+ cookieConfigured: boolean
+ apiKeyConfigured: boolean
+ }>('minimaxCredentials:getStatus')
+ expect(status).toEqual({
+ configured: true,
+ cookieConfigured: true,
+ apiKeyConfigured: false
+ })
+ })
+
+ it('returns apiKeyConfigured true on getStatus when the API key store has a key', async () => {
+ hasMiniMaxApiKeyMock.mockReturnValue(true)
+ registerMiniMaxCredentialsHandlers(null)
+ const status = await invoke<{
+ configured: boolean
+ cookieConfigured: boolean
+ apiKeyConfigured: boolean
+ }>('minimaxCredentials:getStatus')
+ expect(status).toEqual({
+ configured: true,
+ cookieConfigured: false,
+ apiKeyConfigured: true
+ })
})
it('persists the cookie and reports configured after saveCookie', async () => {
hasMiniMaxSessionCookieMock.mockReturnValueOnce(true)
registerMiniMaxCredentialsHandlers(null)
- const status = await invoke<{ configured: boolean }>(
- 'minimaxCredentials:saveCookie',
- '_token=abc; minimax_group_id_v2=42'
- )
+ const status = await invoke<{
+ configured: boolean
+ cookieConfigured: boolean
+ apiKeyConfigured: boolean
+ }>('minimaxCredentials:saveCookie', '_token=abc; minimax_group_id_v2=42')
expect(saveMiniMaxSessionCookieMock).toHaveBeenCalledWith('_token=abc; minimax_group_id_v2=42')
- expect(status).toEqual({ configured: true })
+ expect(status).toMatchObject({ configured: true, cookieConfigured: true })
})
it('triggers a rate-limit refresh after saveCookie when a service is provided', async () => {
@@ -113,11 +152,15 @@ describe('registerMiniMaxCredentialsHandlers', () => {
const { refresh, invalidateMiniMaxCredentialState, service } = makeRefreshMock()
hasMiniMaxSessionCookieMock.mockReturnValueOnce(false)
registerMiniMaxCredentialsHandlers(service as RateLimitService)
- const status = await invoke<{ configured: boolean }>('minimaxCredentials:clearCookie')
+ const status = await invoke<{
+ configured: boolean
+ cookieConfigured: boolean
+ apiKeyConfigured: boolean
+ }>('minimaxCredentials:clearCookie')
expect(clearMiniMaxSessionCookieMock).toHaveBeenCalledTimes(1)
expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1)
expect(clearMiniMaxSessionCookieJarMock).toHaveBeenCalledTimes(1)
- expect(status).toEqual({ configured: false })
+ expect(status).toMatchObject({ configured: false, cookieConfigured: false })
await new Promise((resolve) => setImmediate(resolve))
expect(refresh).toHaveBeenCalledTimes(1)
})
@@ -129,12 +172,16 @@ describe('registerMiniMaxCredentialsHandlers', () => {
hasMiniMaxSessionCookieMock.mockReturnValueOnce(false)
registerMiniMaxCredentialsHandlers(service as RateLimitService)
- const status = await invoke<{ configured: boolean }>('minimaxCredentials:clearCookie')
+ const status = await invoke<{
+ configured: boolean
+ cookieConfigured: boolean
+ apiKeyConfigured: boolean
+ }>('minimaxCredentials:clearCookie')
expect(clearMiniMaxSessionCookieMock).toHaveBeenCalledTimes(1)
expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1)
expect(clearMiniMaxSessionCookieJarMock).toHaveBeenCalledTimes(1)
- expect(status).toEqual({ configured: false })
+ expect(status).toMatchObject({ configured: false, cookieConfigured: false })
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('failed to clear session cookie jar after credential clear'),
expect.any(Error)
@@ -159,4 +206,61 @@ describe('registerMiniMaxCredentialsHandlers', () => {
expect.any(Error)
)
})
+
+ it('persists the API key and reports apiKeyConfigured after saveApiKey', async () => {
+ hasMiniMaxApiKeyMock.mockReturnValueOnce(true)
+ registerMiniMaxCredentialsHandlers(null)
+ const status = await invoke<{
+ configured: boolean
+ cookieConfigured: boolean
+ apiKeyConfigured: boolean
+ }>('minimaxCredentials:saveApiKey', 'sk-test-1234567890')
+ expect(saveMiniMaxApiKeyMock).toHaveBeenCalledWith('sk-test-1234567890')
+ expect(status).toMatchObject({ configured: true, apiKeyConfigured: true })
+ })
+
+ it('rejects non-string API keys on saveApiKey', async () => {
+ registerMiniMaxCredentialsHandlers(null)
+ await expect(invoke('minimaxCredentials:saveApiKey', 12345)).rejects.toThrow(/must be a string/)
+ expect(saveMiniMaxApiKeyMock).not.toHaveBeenCalled()
+ })
+
+ it('triggers a rate-limit refresh after saveApiKey when a service is provided', async () => {
+ const { refresh, invalidateMiniMaxCredentialState, service } = makeRefreshMock()
+ registerMiniMaxCredentialsHandlers(service as RateLimitService)
+ await invoke('minimaxCredentials:saveApiKey', 'sk-test-1234567890')
+ await new Promise((resolve) => setImmediate(resolve))
+ expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1)
+ expect(refresh).toHaveBeenCalledTimes(1)
+ })
+
+ it('clears the API key and triggers a refresh on clearApiKey', async () => {
+ const { refresh, invalidateMiniMaxCredentialState, service } = makeRefreshMock()
+ hasMiniMaxApiKeyMock.mockReturnValueOnce(false)
+ registerMiniMaxCredentialsHandlers(service as RateLimitService)
+ const status = await invoke<{
+ configured: boolean
+ cookieConfigured: boolean
+ apiKeyConfigured: boolean
+ }>('minimaxCredentials:clearApiKey')
+ expect(clearMiniMaxApiKeyMock).toHaveBeenCalledTimes(1)
+ expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1)
+ expect(status).toMatchObject({ configured: false, apiKeyConfigured: false })
+ await new Promise((resolve) => setImmediate(resolve))
+ expect(refresh).toHaveBeenCalledTimes(1)
+ })
+
+ it('reports configured true when either cookie or API key is set', async () => {
+ hasMiniMaxSessionCookieMock.mockReturnValue(true)
+ hasMiniMaxApiKeyMock.mockReturnValue(true)
+ registerMiniMaxCredentialsHandlers(null)
+ const status = await invoke<{
+ configured: boolean
+ cookieConfigured: boolean
+ apiKeyConfigured: boolean
+ }>('minimaxCredentials:getStatus')
+ expect(status.configured).toBe(true)
+ expect(status.cookieConfigured).toBe(true)
+ expect(status.apiKeyConfigured).toBe(true)
+ })
})
diff --git a/src/main/ipc/minimax-credentials.ts b/src/main/ipc/minimax-credentials.ts
index 97eefcd7116..bd0368a1c9e 100644
--- a/src/main/ipc/minimax-credentials.ts
+++ b/src/main/ipc/minimax-credentials.ts
@@ -4,18 +4,31 @@ import {
hasMiniMaxSessionCookie,
saveMiniMaxSessionCookie
} from '../minimax/minimax-cookie-store'
+import {
+ clearMiniMaxApiKey,
+ hasMiniMaxApiKey,
+ saveMiniMaxApiKey
+} from '../minimax/minimax-api-key-store'
import { clearMiniMaxSessionCookieJar } from '../rate-limits/minimax-request-context'
import type { RateLimitService } from '../rate-limits/service'
export type MiniMaxCredentialsStatus = {
configured: boolean
+ cookieConfigured: boolean
+ apiKeyConfigured: boolean
}
function getMiniMaxCredentialsStatus(): MiniMaxCredentialsStatus {
- return { configured: hasMiniMaxSessionCookie() }
+ const cookieConfigured = hasMiniMaxSessionCookie()
+ const apiKeyConfigured = hasMiniMaxApiKey()
+ return {
+ configured: cookieConfigured || apiKeyConfigured,
+ cookieConfigured,
+ apiKeyConfigured
+ }
}
-// Why: fire-and-forget — callers get the persisted cookie status immediately;
+// Why: fire-and-forget — callers get the persisted credential status immediately;
// the rate-limit refresh runs in the background and only logs on failure.
function refreshAfterMiniMaxCredentialChange(
rateLimits: RateLimitService | null,
@@ -49,4 +62,17 @@ export function registerMiniMaxCredentialsHandlers(rateLimits: RateLimitService
refreshAfterMiniMaxCredentialChange(rateLimits, 'clear')
return getMiniMaxCredentialsStatus()
})
+ ipcMain.handle('minimaxCredentials:saveApiKey', (_event, key: string) => {
+ if (typeof key !== 'string') {
+ throw new Error('MiniMax API key must be a string')
+ }
+ saveMiniMaxApiKey(key)
+ refreshAfterMiniMaxCredentialChange(rateLimits, 'save')
+ return getMiniMaxCredentialsStatus()
+ })
+ ipcMain.handle('minimaxCredentials:clearApiKey', () => {
+ clearMiniMaxApiKey()
+ refreshAfterMiniMaxCredentialChange(rateLimits, 'clear')
+ return getMiniMaxCredentialsStatus()
+ })
}
diff --git a/src/main/ipc/notification-burst-cooldown.ts b/src/main/ipc/notification-burst-cooldown.ts
index e7616c57746..91e879a7e47 100644
--- a/src/main/ipc/notification-burst-cooldown.ts
+++ b/src/main/ipc/notification-burst-cooldown.ts
@@ -1,37 +1 @@
-const NOTIFICATION_COOLDOWN_MS = 5000
-const MAX_RECENT_NOTIFICATION_KEYS = 50
-
-function pruneRecentNotifications(recentNotifications: Map, now: number): void {
- if (recentNotifications.size <= MAX_RECENT_NOTIFICATION_KEYS) {
- return
- }
-
- for (const [key, ts] of recentNotifications) {
- if (now - ts >= NOTIFICATION_COOLDOWN_MS) {
- recentNotifications.delete(key)
- }
- }
-
- while (recentNotifications.size > MAX_RECENT_NOTIFICATION_KEYS) {
- const oldest = recentNotifications.keys().next()
- if (oldest.done) {
- break
- }
- recentNotifications.delete(oldest.value)
- }
-}
-
-export function reserveNotificationCooldown(
- recentNotifications: Map,
- dedupeKey: string,
- now: number
-): boolean {
- const lastSentAt = recentNotifications.get(dedupeKey) ?? 0
- if (now - lastSentAt < NOTIFICATION_COOLDOWN_MS) {
- return false
- }
- recentNotifications.delete(dedupeKey)
- recentNotifications.set(dedupeKey, now)
- pruneRecentNotifications(recentNotifications, now)
- return true
-}
+export { reserveNotificationCooldown } from '../../shared/notification-burst-cooldown'
diff --git a/src/main/ipc/notification-options.ts b/src/main/ipc/notification-options.ts
index a2553f05a3c..de05fc0c38a 100644
--- a/src/main/ipc/notification-options.ts
+++ b/src/main/ipc/notification-options.ts
@@ -57,12 +57,7 @@ function buildAgentTaskCompleteNotificationOptions(
const agentLabel = formatNotificationAgentLabel(args.agentType)
const worktreeContext = formatNotificationWorktreeContext(args)
- const statusText =
- args.agentState === 'blocked' || args.agentState === 'waiting'
- ? 'needs input'
- : args.agentState === 'done' && args.agentInterrupted
- ? 'stopped'
- : 'finished'
+ const statusText = formatAgentNotificationStatusText(args)
return {
title: `${worktreeContext} - ${agentLabel} ${statusText}`,
@@ -70,6 +65,19 @@ function buildAgentTaskCompleteNotificationOptions(
}
}
+// Why (#4375): a still-working agent must never be announced as finished. Only an
+// explicit terminal state, or no state at all (the hook snapshot expired and the
+// notification itself is the completion signal), may say "finished".
+function formatAgentNotificationStatusText(args: NotificationDispatchRequest): string {
+ if (args.agentState === 'blocked' || args.agentState === 'waiting') {
+ return 'needs input'
+ }
+ if (args.agentState === 'working') {
+ return 'working'
+ }
+ return args.agentState === 'done' && args.agentInterrupted ? 'stopped' : 'finished'
+}
+
function formatNotificationWorktreeContext(args: NotificationDispatchRequest): string {
const worktreeLabel = normalizeNotificationText(
args.worktreeLabel,
diff --git a/src/main/ipc/notifications-message-formatting.test.ts b/src/main/ipc/notifications-message-formatting.test.ts
index 4fcbc3e0b64..677c3131203 100644
--- a/src/main/ipc/notifications-message-formatting.test.ts
+++ b/src/main/ipc/notifications-message-formatting.test.ts
@@ -278,6 +278,73 @@ describe('registerNotificationHandlers', () => {
expect(options.body.length).toBeLessThanOrEqual(180)
})
+ it.each([
+ { agentState: 'working', expected: 'feat/notis - Claude working' },
+ { agentState: 'blocked', expected: 'feat/notis - Claude needs input' },
+ { agentState: 'waiting', expected: 'feat/notis - Claude needs input' },
+ { agentState: 'done', expected: 'feat/notis - Claude finished' },
+ { agentState: undefined, expected: 'feat/notis - Claude finished' }
+ ])('titles agentState $agentState without claiming a false finish', async (scenario) => {
+ registerNotificationHandlers({
+ getSettings: () => ({
+ notifications: {
+ enabled: true,
+ agentTaskComplete: true,
+ terminalBell: false,
+ suppressWhenFocused: true
+ }
+ })
+ } as never)
+
+ const handler = getDispatchHandler()
+ await handler(
+ {},
+ {
+ source: 'agent-task-complete',
+ worktreeLabel: 'feat/notis',
+ agentType: 'claude',
+ ...(scenario.agentState ? { agentState: scenario.agentState } : {}),
+ agentLastAssistantMessage: 'Ran the suite.'
+ }
+ )
+
+ expect(notificationCtorMock).toHaveBeenCalledWith(
+ expectedNativeNotificationOptions({ title: scenario.expected, body: 'Ran the suite.' })
+ )
+ })
+
+ it('reports an interrupted finish as stopped', async () => {
+ registerNotificationHandlers({
+ getSettings: () => ({
+ notifications: {
+ enabled: true,
+ agentTaskComplete: true,
+ terminalBell: false,
+ suppressWhenFocused: true
+ }
+ })
+ } as never)
+
+ const handler = getDispatchHandler()
+ await handler(
+ {},
+ {
+ source: 'agent-task-complete',
+ worktreeLabel: 'feat/notis',
+ agentType: 'claude',
+ agentState: 'done',
+ agentInterrupted: true
+ }
+ )
+
+ expect(notificationCtorMock).toHaveBeenCalledWith(
+ expectedNativeNotificationOptions({
+ title: 'feat/notis - Claude stopped',
+ body: 'Claude stopped.'
+ })
+ )
+ })
+
it('uses tool context before falling back when no prompt or assistant preview exists', async () => {
registerNotificationHandlers({
getSettings: () => ({
@@ -308,7 +375,7 @@ describe('registerNotificationHandlers', () => {
expect(notificationCtorMock).toHaveBeenCalledWith(
expectedNativeNotificationOptions({
- title: 'feat/notis - Agent finished',
+ title: 'feat/notis - Agent working',
body: 'Using Bash: pnpm test'
})
)
diff --git a/src/main/ipc/notifications-mobile-fanout.test.ts b/src/main/ipc/notifications-mobile-fanout.test.ts
index 94d2535a3cc..ab797293042 100644
--- a/src/main/ipc/notifications-mobile-fanout.test.ts
+++ b/src/main/ipc/notifications-mobile-fanout.test.ts
@@ -71,15 +71,17 @@ describe('registerNotificationHandlers', () => {
expect(dispatchMobileNotification).toHaveBeenCalledWith({
type: 'notification',
+ emittedAt: expect.any(Number),
source: 'agent-task-complete',
title: 'feat/notis - Hermes finished',
body: 'The diff updates notification formatting.',
- worktreeId: 'repo::wt1'
+ worktreeId: 'repo::wt1',
+ agentState: 'done'
})
expect(notificationCtorMock).not.toHaveBeenCalled()
})
- it('does not dispatch mobile notifications when notifications are disabled', async () => {
+ it('offers disabled desktop events to independently configured phones', async () => {
const dispatchMobileNotification = vi.fn()
registerNotificationHandlers(
{
@@ -101,10 +103,12 @@ describe('registerNotificationHandlers', () => {
reason: 'disabled'
})
- expect(dispatchMobileNotification).not.toHaveBeenCalled()
+ expect(dispatchMobileNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ desktopAllowed: false })
+ )
})
- it('does not dispatch mobile notifications when the source is disabled', async () => {
+ it('marks a disabled desktop source for phones following desktop settings', async () => {
const dispatchMobileNotification = vi.fn()
registerNotificationHandlers(
{
@@ -126,7 +130,9 @@ describe('registerNotificationHandlers', () => {
reason: 'source-disabled'
})
- expect(dispatchMobileNotification).not.toHaveBeenCalled()
+ expect(dispatchMobileNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ desktopAllowed: false })
+ )
})
it('dispatches one mobile notification when the active worktree is focused on desktop', async () => {
@@ -173,7 +179,7 @@ describe('registerNotificationHandlers', () => {
expect(notificationCtorMock).not.toHaveBeenCalled()
})
- it('does not dispatch mobile notifications for cooldown-suppressed bursts', async () => {
+ it('preserves different mobile event categories before per-phone burst suppression', async () => {
const dispatchMobileNotification = vi.fn()
registerNotificationHandlers(
{
@@ -198,7 +204,7 @@ describe('registerNotificationHandlers', () => {
reason: 'cooldown'
})
- expect(dispatchMobileNotification).toHaveBeenCalledTimes(1)
+ expect(dispatchMobileNotification).toHaveBeenCalledTimes(2)
expect(dispatchMobileNotification).toHaveBeenCalledWith(
expect.objectContaining({ source: 'agent-task-complete', worktreeId: 'repo::wt1' })
)
diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts
index 28f6bfd95e5..8d8098f538b 100644
--- a/src/main/ipc/notifications.ts
+++ b/src/main/ipc/notifications.ts
@@ -119,34 +119,43 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
}
const settings = store.getSettings().notifications
- if (!settings.enabled) {
- return { delivered: false, reason: 'disabled' }
- }
-
- if (
- (args.source === 'agent-task-complete' && !settings.agentTaskComplete) ||
- (args.source === 'terminal-bell' && !settings.terminalBell)
- ) {
- return { delivered: false, reason: 'source-disabled' }
- }
+ const desktopAllowed =
+ settings.enabled &&
+ (args.source !== 'agent-task-complete' || settings.agentTaskComplete) &&
+ (args.source !== 'terminal-bell' || settings.terminalBell)
const notificationOptions = buildNotificationOptions(args)
// Why: desktop focus only means this computer sees the worktree; the paired phone may still need the alert.
if (runtime && args.source !== 'test') {
const dedupeKey = args.worktreeId ?? args.worktreeLabel ?? 'global'
- if (reserveNotificationCooldown(recentMobileNotifications, dedupeKey, Date.now())) {
+ if (
+ reserveNotificationCooldown(
+ recentMobileNotifications,
+ JSON.stringify([desktopAllowed, args.source, args.agentState, dedupeKey]),
+ Date.now()
+ )
+ ) {
runtime.dispatchMobileNotification({
type: 'notification',
+ emittedAt: Date.now(),
source: args.source,
+ ...(!desktopAllowed ? { desktopAllowed: false } : {}),
title: notificationOptions.title,
body: notificationOptions.body,
worktreeId: args.worktreeId,
- ...(args.notificationId ? { notificationId: args.notificationId } : {})
+ ...(args.notificationId ? { notificationId: args.notificationId } : {}),
+ // Why: background push needs the agent's real state to pick "needs input"
+ // vs "finished" — and to stay silent while the agent is still working.
+ ...(args.agentState ? { agentState: args.agentState } : {})
})
}
}
+ if (!desktopAllowed) {
+ return { delivered: false, reason: settings.enabled ? 'source-disabled' : 'disabled' }
+ }
+
const browserWindow =
BrowserWindow.getAllWindows().find((window) => !window.isDestroyed()) ?? null
if (
diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts
index 908d0730221..3c65796b250 100644
--- a/src/main/ipc/pty/host-env/assembly.ts
+++ b/src/main/ipc/pty/host-env/assembly.ts
@@ -1,4 +1,3 @@
-import { join, delimiter } from 'node:path'
import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing'
import {
detectExplicitPiAgentKindFromCommand,
@@ -10,13 +9,12 @@ import { mimoCodeHookService } from '../../../mimo/hook-service'
import { agentHookServer } from '../../../agent-hooks/server'
import { wslHookRelayManager } from '../../../agent-hooks/wsl-hook-relay-manager'
import { piTitlebarExtensionService } from '../../../pi/titlebar-extension-service'
-import { ensureLinuxTerminalOrcaCliShimDir } from '../../../cli/linux-terminal-orca-cli-shim'
+import { prependOrcaCliDirToChildPath } from '../../../cli/orca-cli-child-path'
import { stripLegacyTerminalShimEnv } from '../../../pty/legacy-terminal-shim-dir'
-import { resolvePathEnvKey, mergePersistedWindowsPath } from '../../../pty/windows-environment-path'
+import { mergePersistedWindowsPath } from '../../../pty/windows-environment-path'
import { resolveCodexShellLaunchPreflightCommand } from '../../../pty/codex-shell-launch-preflight'
import { buildConfiguredProxyEnv } from '../../../../shared/network-proxy'
import type { BuildPtyHostEnvOptions } from './types'
-import { readInheritedPath } from './path'
import { stripInheritedOrcaCodexHomeOverride } from './codex-home'
import {
clearPiAgentShadowEnv,
@@ -235,34 +233,11 @@ export function buildPtyHostEnv(
}
delete baseEnv.ORCA_CLI_COMMAND
}
- // Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca.
- if (!opts.isPackaged) {
- const devCliBin = join(opts.userDataPath, 'cli', 'bin')
- const inheritedPath = readInheritedPath(baseEnv)
- // Why: an empty PATH segment resolves as `.` in some shells (commands run from cwd); avoid a trailing delimiter.
- baseEnv[resolvePathEnvKey(baseEnv, process.platform)] = inheritedPath
- ? `${devCliBin}${delimiter}${inheritedPath}`
- : devCliBin
- } else if (process.platform === 'linux') {
- // Why: bare-`orca` shim scoped to Orca PTYs — Linux CLI installs as `orca-ide` to avoid shadowing GNOME's /usr/bin/orca screen reader (stablyai/orca#7904).
- const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath: opts.userDataPath })
- if (shimDir) {
- const inheritedEntries = readInheritedPath(baseEnv)
- .split(delimiter)
- .filter((entry) => entry.length > 0 && entry !== shimDir)
- baseEnv.PATH = [shimDir, ...inheritedEntries].join(delimiter)
- }
- } else if (
- opts.resourcesPath &&
- (process.platform === 'darwin' || process.platform === 'win32')
- ) {
- // Why: global CLI registration is optional, but agents in Orca-managed PTYs must always reach this app's bundled CLI.
- const bundledCliBin = join(opts.resourcesPath, 'bin')
- const inheritedPath = readInheritedPath(baseEnv)
- baseEnv[resolvePathEnvKey(baseEnv, process.platform)] = inheritedPath
- ? `${bundledCliBin}${delimiter}${inheritedPath}`
- : bundledCliBin
- }
+ prependOrcaCliDirToChildPath(baseEnv, {
+ isPackaged: opts.isPackaged,
+ userDataPath: opts.userDataPath,
+ resourcesPath: opts.resourcesPath
+ })
if (
opts.routeBrowserOpensToClient === true &&
diff --git a/src/main/ipc/pty/host-env/path.ts b/src/main/ipc/pty/host-env/path.ts
index 226ad8691fe..e9091864464 100644
--- a/src/main/ipc/pty/host-env/path.ts
+++ b/src/main/ipc/pty/host-env/path.ts
@@ -2,8 +2,11 @@ import { delimiter } from 'node:path'
import { isLegacyTerminalShimPathEntry } from '../../../pty/legacy-terminal-shim-dir'
import { resolvePathEnvKey } from '../../../pty/windows-environment-path'
-export function readInheritedPath(baseEnv: Record): string {
- const pathKey = resolvePathEnvKey(baseEnv, process.platform)
+export function readInheritedPath(
+ baseEnv: Record,
+ platform: NodeJS.Platform = process.platform
+): string {
+ const pathKey = resolvePathEnvKey(baseEnv, platform)
return baseEnv[pathKey] ?? process.env[pathKey] ?? ''
}
diff --git a/src/main/ipc/worktrees-removal-recovery.test.ts b/src/main/ipc/worktrees-removal-recovery.test.ts
index 49dc7b633bd..e8571449321 100644
--- a/src/main/ipc/worktrees-removal-recovery.test.ts
+++ b/src/main/ipc/worktrees-removal-recovery.test.ts
@@ -500,7 +500,9 @@ describe('registerWorktreeHandlers', () => {
runtime: runtimeStub,
resolvedWorktreeId: worktreeId,
localProvider: ptyProvider,
- onPtyStopped: clearProviderPtyStateMock
+ onPtyStopped: clearProviderPtyStateMock,
+ // Folder-workspace removal best-effort closes structured sessions the PTY sweeps cannot see.
+ closeStructuredSessions: true
})
expect(killAllProcessesForWorktreeMock.mock.invocationCallOrder[0]).toBeLessThan(
store.removeWorktreeMeta.mock.invocationCallOrder[0]
@@ -564,7 +566,8 @@ describe('registerWorktreeHandlers', () => {
localProvider: sshPtyProvider,
onPtyStopped: clearProviderPtyStateMock,
includeProviderInventory: true,
- includeLocalRegistry: false
+ includeLocalRegistry: false,
+ closeStructuredSessions: true
})
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'ssh:conn-1')
expect(advertisedUrlWatcherForgetWorktreeMock).not.toHaveBeenCalled()
@@ -597,7 +600,8 @@ describe('registerWorktreeHandlers', () => {
localProvider: runtimePtyProvider,
onPtyStopped: clearProviderPtyStateMock,
includeProviderInventory: false,
- includeLocalRegistry: false
+ includeLocalRegistry: false,
+ closeStructuredSessions: true
})
expect(getSshPtyProviderMock).not.toHaveBeenCalled()
})
diff --git a/src/main/ipc/worktrees/removal/remove-folder-workspace.ts b/src/main/ipc/worktrees/removal/remove-folder-workspace.ts
index c03b1449136..916f4574a92 100644
--- a/src/main/ipc/worktrees/removal/remove-folder-workspace.ts
+++ b/src/main/ipc/worktrees/removal/remove-folder-workspace.ts
@@ -43,6 +43,9 @@ export async function removeFolderWorkspace(
: {}),
localProvider: sshPtyProvider ?? getLocalPtyProvider(),
onPtyStopped: clearProviderPtyState,
+ // A structured session is on no PTY surface, so the sweeps above leave it attached to a
+ // workspace this removal is about to forget. Closed best-effort, exactly like those PTYs.
+ closeStructuredSessions: true,
...(externalHost
? {
includeProviderInventory: ownerHost?.kind === 'ssh' && Boolean(sshPtyProvider),
diff --git a/src/main/minimax/minimax-api-key-store.test.ts b/src/main/minimax/minimax-api-key-store.test.ts
new file mode 100644
index 00000000000..9dd3ebdea01
--- /dev/null
+++ b/src/main/minimax/minimax-api-key-store.test.ts
@@ -0,0 +1,184 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type * as MiniMaxApiKeyStore from './minimax-api-key-store'
+
+const safeStorageMock = vi.hoisted(() => ({
+ isEncryptionAvailable: vi.fn(() => true),
+ encryptString: vi.fn((value: string) => Buffer.from(value)),
+ decryptString: vi.fn((value: Buffer) => value.toString('utf8'))
+}))
+
+const electronMock = vi.hoisted(() => ({
+ safeStorage: safeStorageMock
+}))
+
+vi.mock('electron', () => electronMock)
+
+const existsSyncMock = vi.fn()
+const readFileSyncMock = vi.fn()
+const rmSyncMock = vi.fn()
+const hardenExistingSecureFileMock = vi.fn()
+const writeSecureFileMock = vi.fn()
+const homedirMock = vi.fn(() => '/home/test')
+
+vi.mock('node:fs', () => ({
+ existsSync: existsSyncMock,
+ readFileSync: readFileSyncMock,
+ rmSync: rmSyncMock
+}))
+
+vi.mock('node:os', () => ({
+ homedir: homedirMock
+}))
+
+vi.mock('node:path', () => ({
+ join: (...parts: string[]) => parts.join('/')
+}))
+
+vi.mock('../../shared/secure-file', () => ({
+ hardenExistingSecureFile: hardenExistingSecureFileMock,
+ writeSecureFile: writeSecureFileMock
+}))
+
+const storePath = '/home/test/.orca/minimax-api-key.enc'
+const envelope = (kind: 'encrypted' | 'plaintext', value: string): string =>
+ `orca-minimax-api-key:v1:${kind}:${Buffer.from(value, 'utf8').toString('base64')}`
+
+async function loadStore(): Promise {
+ return await import('./minimax-api-key-store')
+}
+
+describe('minimax-api-key-store', () => {
+ beforeEach(() => {
+ existsSyncMock.mockReset()
+ readFileSyncMock.mockReset()
+ rmSyncMock.mockReset()
+ hardenExistingSecureFileMock.mockReset()
+ writeSecureFileMock.mockReset()
+ safeStorageMock.isEncryptionAvailable.mockReset()
+ safeStorageMock.encryptString.mockReset()
+ safeStorageMock.decryptString.mockReset()
+ safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
+ safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value))
+ safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf8'))
+ })
+
+ afterEach(() => {
+ vi.resetModules()
+ })
+
+ it('returns false when no file exists yet', async () => {
+ existsSyncMock.mockReturnValue(false)
+ const store = await loadStore()
+ expect(store.hasMiniMaxApiKey()).toBe(false)
+ expect(hardenExistingSecureFileMock).not.toHaveBeenCalled()
+ })
+
+ it('hardens the key file when checking status for an existing key', async () => {
+ existsSyncMock.mockReturnValue(true)
+ const store = await loadStore()
+ expect(store.hasMiniMaxApiKey()).toBe(true)
+ expect(hardenExistingSecureFileMock).toHaveBeenCalledWith(storePath)
+ })
+
+ it('still reports an existing key when status-path hardening fails', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ existsSyncMock.mockReturnValue(true)
+ hardenExistingSecureFileMock.mockImplementation(() => {
+ throw new Error('permission denied')
+ })
+ const store = await loadStore()
+ expect(store.hasMiniMaxApiKey()).toBe(true)
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining('Failed to harden MiniMax API key file'),
+ expect.any(Error)
+ )
+ warn.mockRestore()
+ })
+
+ it('writes the key using safeStorage when encryption is available', async () => {
+ existsSyncMock.mockReturnValue(false)
+ const store = await loadStore()
+ store.saveMiniMaxApiKey('sk-test-1234567890')
+ expect(safeStorageMock.encryptString).toHaveBeenCalledWith('sk-test-1234567890')
+ expect(writeSecureFileMock).toHaveBeenCalledWith(
+ storePath,
+ envelope('encrypted', 'sk-test-1234567890')
+ )
+ })
+
+ it('warns and writes plaintext when safeStorage is unavailable', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
+ existsSyncMock.mockReturnValue(false)
+ const store = await loadStore()
+ store.saveMiniMaxApiKey('sk-test-1234567890')
+ expect(writeSecureFileMock).toHaveBeenCalledWith(
+ storePath,
+ envelope('plaintext', 'sk-test-1234567890')
+ )
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('safeStorage encryption unavailable'))
+ warn.mockRestore()
+ })
+
+ it('refuses empty keys', async () => {
+ const store = await loadStore()
+ expect(() => store.saveMiniMaxApiKey(' ')).toThrow(/required/)
+ })
+
+ it('reads decrypted key from disk and caches it', async () => {
+ existsSyncMock.mockReturnValue(true)
+ readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'encrypted-payload')))
+ safeStorageMock.decryptString.mockReturnValue('sk-cached-key')
+ const store = await loadStore()
+ const first = store.readMiniMaxApiKey()
+ const second = store.readMiniMaxApiKey()
+ expect(first).toBe('sk-cached-key')
+ expect(second).toBe(first)
+ expect(hardenExistingSecureFileMock).toHaveBeenCalledTimes(1)
+ expect(hardenExistingSecureFileMock).toHaveBeenCalledWith(storePath)
+ expect(safeStorageMock.decryptString).toHaveBeenCalledTimes(1)
+ expect(safeStorageMock.decryptString).toHaveBeenCalledWith(Buffer.from('encrypted-payload'))
+ })
+
+ it('returns null when no file exists', async () => {
+ existsSyncMock.mockReturnValue(false)
+ const store = await loadStore()
+ expect(store.readMiniMaxApiKey()).toBeNull()
+ })
+
+ it('throws for encrypted envelopes when safeStorage is unavailable', async () => {
+ safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
+ existsSyncMock.mockReturnValue(true)
+ readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'encrypted-payload')))
+ const store = await loadStore()
+ expect(() => store.readMiniMaxApiKey()).toThrow(/could not be decrypted/)
+ })
+
+ it('throws when decryption fails', async () => {
+ existsSyncMock.mockReturnValue(true)
+ readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'encrypted-payload')))
+ safeStorageMock.decryptString.mockImplementation(() => {
+ throw new Error('boom')
+ })
+ const store = await loadStore()
+ expect(() => store.readMiniMaxApiKey()).toThrow(/could not be decrypted/)
+ })
+
+ it('throws for non-envelope files (legacy safeStorage bytes with no prefix)', async () => {
+ existsSyncMock.mockReturnValue(true)
+ readFileSyncMock.mockReturnValue(Buffer.from('raw-bytes-without-envelope'))
+ const store = await loadStore()
+ expect(() => store.readMiniMaxApiKey()).toThrow(/could not be decrypted/)
+ })
+
+ it('clears the cached key and removes the file', async () => {
+ existsSyncMock.mockReturnValueOnce(true)
+ readFileSyncMock.mockReturnValueOnce(Buffer.from(envelope('encrypted', 'encrypted-payload')))
+ safeStorageMock.decryptString.mockReturnValueOnce('sk-preclear')
+ const store = await loadStore()
+ expect(store.readMiniMaxApiKey()).toBe('sk-preclear')
+ store.clearMiniMaxApiKey()
+ expect(rmSyncMock).toHaveBeenCalledWith(storePath, { force: true })
+ expect(store.readMiniMaxApiKey()).toBeNull()
+ })
+})
diff --git a/src/main/minimax/minimax-api-key-store.ts b/src/main/minimax/minimax-api-key-store.ts
new file mode 100644
index 00000000000..efd0af65db9
--- /dev/null
+++ b/src/main/minimax/minimax-api-key-store.ts
@@ -0,0 +1,127 @@
+import { safeStorage } from 'electron'
+import { existsSync, readFileSync, rmSync } from 'node:fs'
+import { homedir } from 'node:os'
+import { join } from 'node:path'
+import { hardenExistingSecureFile, writeSecureFile } from '../../shared/secure-file'
+
+const MINIMAX_API_KEY_FILE = 'minimax-api-key.enc'
+const API_KEY_ENVELOPE_PREFIX = 'orca-minimax-api-key:v1:'
+let cachedMiniMaxApiKey: string | null = null
+let warnedMiniMaxApiKeyStatusHardenFailure = false
+
+type MiniMaxApiKeyEnvelope = {
+ kind: 'encrypted' | 'plaintext'
+ payload: Buffer
+}
+
+function getOrcaDir(): string {
+ return join(homedir(), '.orca')
+}
+
+function getMiniMaxApiKeyPath(): string {
+ return join(getOrcaDir(), MINIMAX_API_KEY_FILE)
+}
+
+function encodeApiKeyEnvelope(kind: MiniMaxApiKeyEnvelope['kind'], payload: Buffer): string {
+ return `${API_KEY_ENVELOPE_PREFIX}${kind}:${payload.toString('base64')}`
+}
+
+function decodeApiKeyEnvelope(raw: Buffer): MiniMaxApiKeyEnvelope {
+ const text = raw.toString('utf8')
+ if (!text.startsWith(API_KEY_ENVELOPE_PREFIX)) {
+ throw new Error('MiniMax API key could not be decrypted')
+ }
+ const rest = text.slice(API_KEY_ENVELOPE_PREFIX.length)
+ const separator = rest.indexOf(':')
+ if (separator === -1) {
+ throw new Error('MiniMax API key could not be decrypted')
+ }
+ const kind = rest.slice(0, separator)
+ if (kind !== 'encrypted' && kind !== 'plaintext') {
+ throw new Error('MiniMax API key could not be decrypted')
+ }
+ return {
+ kind,
+ payload: Buffer.from(rest.slice(separator + 1), 'base64')
+ }
+}
+
+function readEnvelope(envelope: MiniMaxApiKeyEnvelope): string {
+ if (envelope.kind === 'plaintext') {
+ return envelope.payload.toString('utf8')
+ }
+ if (!safeStorage.isEncryptionAvailable()) {
+ throw new Error('MiniMax API key could not be decrypted')
+ }
+ return safeStorage.decryptString(envelope.payload)
+}
+
+export function hasMiniMaxApiKey(): boolean {
+ const keyPath = getMiniMaxApiKeyPath()
+ if (!existsSync(keyPath)) {
+ return false
+ }
+ try {
+ hardenExistingSecureFile(keyPath)
+ } catch (error) {
+ if (!warnedMiniMaxApiKeyStatusHardenFailure) {
+ warnedMiniMaxApiKeyStatusHardenFailure = true
+ console.warn('[minimax] Failed to harden MiniMax API key file while checking status', error)
+ }
+ }
+ return true
+}
+
+export function saveMiniMaxApiKey(key: string): void {
+ const trimmed = key.trim()
+ if (!trimmed) {
+ throw new Error('MiniMax API key is required')
+ }
+ if (safeStorage.isEncryptionAvailable()) {
+ writeSecureFile(
+ getMiniMaxApiKeyPath(),
+ encodeApiKeyEnvelope('encrypted', safeStorage.encryptString(trimmed))
+ )
+ cachedMiniMaxApiKey = trimmed
+ return
+ }
+ console.warn(
+ '[minimax] safeStorage encryption unavailable — storing MiniMax API key in plaintext'
+ )
+ writeSecureFile(
+ getMiniMaxApiKeyPath(),
+ encodeApiKeyEnvelope('plaintext', Buffer.from(trimmed, 'utf8'))
+ )
+ cachedMiniMaxApiKey = trimmed
+}
+
+export function readMiniMaxApiKey(): string | null {
+ if (cachedMiniMaxApiKey !== null) {
+ return cachedMiniMaxApiKey
+ }
+ const keyPath = getMiniMaxApiKeyPath()
+ if (!existsSync(keyPath)) {
+ return null
+ }
+ // Why: keep hardening out of the decode/decrypt try below so a chmod/ACL
+ // failure isn't misreported as a decrypt failure (matches hasMiniMaxApiKey).
+ try {
+ hardenExistingSecureFile(keyPath)
+ } catch (error) {
+ console.warn('[minimax] Failed to harden MiniMax API key file while reading', error)
+ }
+ try {
+ const raw = readFileSync(keyPath)
+ const envelope = decodeApiKeyEnvelope(raw)
+ cachedMiniMaxApiKey = readEnvelope(envelope)
+ return cachedMiniMaxApiKey
+ } catch (error) {
+ console.error('[minimax] failed to decode/decrypt API key', error)
+ throw new Error('MiniMax API key could not be decrypted')
+ }
+}
+
+export function clearMiniMaxApiKey(): void {
+ cachedMiniMaxApiKey = null
+ rmSync(getMiniMaxApiKeyPath(), { force: true })
+}
diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts
index 676a37f63a7..2dc0ac1aeb2 100644
--- a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts
@@ -207,11 +207,46 @@ describe('submission and dispatch state machine', () => {
const items = renderJournalState(state).items
expect(items).toHaveLength(1)
expect(items[0]?.itemId).toBe(agentJournalSubmissionKey('cm_1'))
- // The echo updates content in place; the bubble keeps its original slot.
+ // The echo advances the revision; the submitted bubble keeps its original slot.
expect(items[0]?.sequence).toBe(1)
expect(items[0]?.revision).toBe(1)
})
+ it.each(['codex:thread-1:turn-1:0', 'claude:session-1:user-1'])(
+ 'preserves submitted text and attachments when %s is restored',
+ (providerItemId) => {
+ const body: AgentJournalMessageItem = {
+ kind: 'message',
+ role: 'user',
+ blocks: [
+ { type: 'text', text: '/example-skill inspect this' },
+ { type: 'image-ref', path: '/tmp/original.png' }
+ ]
+ }
+ const state = fold([
+ { ...submission, body, payloadFingerprint: sendFingerprint(body) },
+ {
+ kind: 'dispatch',
+ clientMessageId: 'cm_1',
+ state: 'accepted',
+ providerItemId,
+ reason: null,
+ ...base(2)
+ },
+ {
+ kind: 'item',
+ itemId: providerItemId,
+ revision: 1,
+ body: userText('# Expanded skill instructions'),
+ ...base(3)
+ }
+ ])
+ expect(renderJournalState(state).items).toEqual([
+ expect.objectContaining({ itemId: agentJournalSubmissionKey('cm_1'), body, revision: 1 })
+ ])
+ }
+ )
+
it('adopts a provider echo that arrives before dispatch settles', () => {
const body = userText('early echo')
const state = fold([
diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts
index 3dc4385d797..41625792aa0 100644
--- a/src/main/native-chat/agent-session-journal/journal-reducer.ts
+++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts
@@ -26,6 +26,7 @@ export type JournalReducerState = {
sessionId: string
epoch: string
lastSequence: number
+ lastActivityAt: number
/** Lowest sequence still individually replayable; rows below it were compacted. */
oldestSequence: number
highestFence: number
@@ -45,6 +46,7 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou
sessionId,
epoch,
lastSequence: 0,
+ lastActivityAt: 0,
oldestSequence: 1,
highestFence: 0,
items: new Map(),
@@ -62,6 +64,7 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo
if (row.kind === 'epoch') {
return
}
+ state.lastActivityAt = Math.max(state.lastActivityAt, row.ts)
if (row.kind === 'item') {
const itemId = resolveJournalItemId(state, row.itemId, row.body)
upsertItem(state, itemId, row.revision, {
@@ -190,7 +193,17 @@ function upsertItem(
// so letting a revision advance it makes the row jump past everything that
// landed in between — the provider's own echo of a send revises the submission
// row, which relocated the user's bubble below later rows.
- state.items.set(itemId, { ...next, sequence: existing.sequence, observedAt: existing.observedAt })
+ const submitted =
+ existing.body.kind === 'message' &&
+ existing.body.role === 'user' &&
+ parseAgentJournalItemKey(itemId)?.provider === 'orca'
+ state.items.set(itemId, {
+ ...next,
+ // Provider history may normalize text or omit local attachments from the original send.
+ body: submitted ? existing.body : next.body,
+ sequence: existing.sequence,
+ observedAt: existing.observedAt
+ })
state.tombstones.delete(itemId)
}
diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts
index ab2715d0d86..e2936b2553d 100644
--- a/src/main/native-chat/agent-session-journal/journal-store.ts
+++ b/src/main/native-chat/agent-session-journal/journal-store.ts
@@ -162,6 +162,9 @@ export class AgentSessionJournal {
snapshot = (): AgentJournalSnapshot => renderJournalState(this.state)
+ /** Includes revisions and completion tombstones, whose timestamps disappear from render items. */
+ lastActivityAt = (): number => this.state.lastActivityAt
+
submissions = (): AgentJournalSubmission[] => [...this.state.submissions.values()]
pendingSubmissions = (): AgentJournalSubmission[] =>
diff --git a/src/main/native-chat/agent-session-wire/provider-frame-activity.test.ts b/src/main/native-chat/agent-session-wire/provider-frame-activity.test.ts
new file mode 100644
index 00000000000..79d9e4205cf
--- /dev/null
+++ b/src/main/native-chat/agent-session-wire/provider-frame-activity.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, it } from 'vitest'
+import {
+ MAX_PROVIDER_ACTIVITY_LENGTH,
+ claudeProviderFrameActivity,
+ codexProviderFrameActivity,
+ providerActivityText
+} from './provider-frame-activity'
+
+describe('provider frame activity', () => {
+ it('derives bounded Codex activity without exposing item payloads or opcodes', () => {
+ expect(
+ codexProviderFrameActivity('item/started', {
+ item: { type: 'commandExecution', command: 'printenv SECRET_TOKEN' }
+ })
+ ).toBe('Running a command')
+ expect(
+ codexProviderFrameActivity('item/mcpToolCall/progress', {
+ message: '**Indexing repository symbols**'
+ })
+ ).toBe('Indexing repository symbols')
+ expect(
+ codexProviderFrameActivity(
+ 'item/reasoning/summaryTextDelta',
+ { delta: 'ignored-fragment' },
+ 'Inspecting the session wire'
+ )
+ ).toBe('Inspecting the session wire')
+ expect(codexProviderFrameActivity('item/reasoning/summaryPartAdded', {})).toBeNull()
+ })
+
+ it('uses Claude descriptions and safe semantic status without exposing tool labels', () => {
+ expect(
+ claudeProviderFrameActivity('message:system:task_started', {
+ description: 'Trace the activity channel'
+ })
+ ).toBe('Working on: Trace the activity channel')
+ expect(
+ claudeProviderFrameActivity('message:system:task_progress', {
+ description: 'Reading tests',
+ summary: 'Checking remote compatibility'
+ })
+ ).toBe('Checking remote compatibility')
+ expect(
+ claudeProviderFrameActivity('message:system:task_updated', {
+ patch: { description: 'Validating the renderer' }
+ })
+ ).toBe('Validating the renderer')
+ expect(claudeProviderFrameActivity('message:system:status', { status: 'compacting' })).toBe(
+ 'Compacting the conversation'
+ )
+ expect(
+ claudeProviderFrameActivity('message:system:control_request_progress', {
+ status: 'api_retry'
+ })
+ ).toBe('Retrying a side question')
+ expect(
+ claudeProviderFrameActivity('message:tool_progress', {
+ tool_name: 'ReadSecretFile'
+ })
+ ).toBeNull()
+ })
+
+ it('falls through on protocol noise and bounds long copy', () => {
+ expect(providerActivityText('codex · notification:warning')).toBeNull()
+ expect(providerActivityText('item/reasoning/summaryPartAdded')).toBeNull()
+ expect(providerActivityText('{"file":"contents"}')).toBeNull()
+ const bounded = providerActivityText(`Reviewing ${'long '.repeat(100)}`)
+ expect(Array.from(bounded ?? '').length).toBeLessThanOrEqual(MAX_PROVIDER_ACTIVITY_LENGTH)
+ expect(bounded?.endsWith('…')).toBe(true)
+ })
+
+ it('keeps only the reasoning headline and waits for an unterminated bold header', () => {
+ expect(
+ codexProviderFrameActivity(
+ 'item/reasoning/summaryTextDelta',
+ {},
+ '**Inspecting the workspace**\n\nI am looking at notes.txt before answering.'
+ )
+ ).toBe('Inspecting the workspace')
+ expect(
+ codexProviderFrameActivity('item/reasoning/summaryTextDelta', {}, '**Inspecting the wor')
+ ).toBeUndefined()
+ })
+})
diff --git a/src/main/native-chat/agent-session-wire/provider-frame-activity.ts b/src/main/native-chat/agent-session-wire/provider-frame-activity.ts
new file mode 100644
index 00000000000..336170a4cfe
--- /dev/null
+++ b/src/main/native-chat/agent-session-wire/provider-frame-activity.ts
@@ -0,0 +1,188 @@
+import { normalizeOptionalField } from '../../../shared/agent-status-field-normalization'
+
+export const MAX_PROVIDER_ACTIVITY_LENGTH = 160
+
+type ActivityText = string | null | undefined
+
+function record(value: unknown): Record | null {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+ ? (value as Record)
+ : null
+}
+
+function stringField(source: Record | null, key: string): string | null {
+ const value = source?.[key]
+ return typeof value === 'string' && value.trim() ? value : null
+}
+
+/** A reasoning summary streams as a bold headline plus body; only the headline is activity copy. */
+function reasoningHeadline(text: string | null | undefined): ActivityText {
+ const line = text?.split(/\r?\n/).find((candidate) => candidate.trim())
+ if (!line) {
+ return null
+ }
+ // Hold the previous copy until the closing marker streams in; a half headline would flicker.
+ return /^\s*\*\*/.test(line) && !/\*\*.+\*\*/.test(line) ? undefined : line
+}
+
+/** Keep only a short sentence-shaped preview from provider-declared display fields. */
+export function providerActivityText(value: unknown): string | null {
+ const normalized = normalizeOptionalField(value, MAX_PROVIDER_ACTIVITY_LENGTH + 1)
+ if (!normalized) {
+ return null
+ }
+ const unwrapped = normalized
+ .replace(/^(?:#{1,6}|[-+])\s+/, '')
+ .replace(/^\*\*(.+)\*\*$/, '$1')
+ .replace(/^`(.+)`$/, '$1')
+ .trim()
+ if (
+ !unwrapped ||
+ /^[{[]/.test(unwrapped) ||
+ /^[\w.-]+\s*[·-]\s*(?:notification:|message:|item\/)/i.test(unwrapped) ||
+ (/^[\w:./-]+$/.test(unwrapped) && /[:/]/.test(unwrapped)) ||
+ !/\p{L}/u.test(unwrapped)
+ ) {
+ return null
+ }
+ const characters = Array.from(unwrapped)
+ if (characters.length <= MAX_PROVIDER_ACTIVITY_LENGTH) {
+ return unwrapped
+ }
+ const head = characters.slice(0, MAX_PROVIDER_ACTIVITY_LENGTH - 1).join('')
+ const boundary = head.lastIndexOf(' ')
+ const clipped = boundary >= MAX_PROVIDER_ACTIVITY_LENGTH * 0.6 ? head.slice(0, boundary) : head
+ return `${clipped.trimEnd()}…`
+}
+
+const CODEX_ITEM_ACTIVITY: Readonly> = {
+ agentMessage: 'Drafting a response',
+ plan: 'Updating the plan',
+ reasoning: 'Thinking through the request',
+ commandExecution: 'Running a command',
+ fileChange: 'Editing files',
+ mcpToolCall: 'Using an external tool',
+ dynamicToolCall: 'Using an external tool',
+ functionCallOutput: 'Reviewing tool results',
+ collabAgentToolCall: 'Coordinating with another agent',
+ subAgentActivity: 'Coordinating with another agent',
+ webSearch: 'Searching the web',
+ imageView: 'Inspecting an image',
+ imageGeneration: 'Generating an image',
+ enteredReviewMode: 'Reviewing changes',
+ exitedReviewMode: 'Reviewing changes',
+ contextCompaction: 'Compacting the conversation',
+ sleep: 'Waiting briefly',
+ hookPrompt: 'Processing workspace guidance'
+}
+
+export function codexProviderFrameActivity(
+ method: string,
+ payload: unknown,
+ reasoningText?: string | null
+): ActivityText {
+ const source = record(payload)
+ if (method === 'item/mcpToolCall/progress') {
+ return providerActivityText(stringField(source, 'message'))
+ }
+ if (method === 'item/reasoning/summaryTextDelta') {
+ const headline = reasoningHeadline(reasoningText)
+ return headline === undefined ? undefined : providerActivityText(headline)
+ }
+ if (method === 'item/reasoning/summaryPartAdded') {
+ return null
+ }
+ if (method !== 'item/started') {
+ return undefined
+ }
+ const item = record(source?.item)
+ const itemType = stringField(item, 'type')
+ return itemType ? (CODEX_ITEM_ACTIVITY[itemType] ?? null) : null
+}
+
+export function claudeProviderFrameActivity(kind: string, payload: unknown): ActivityText {
+ const source = record(payload)
+ if (kind === 'message:system:task_started') {
+ if (source?.ambient === true || source?.skip_transcript === true) {
+ return null
+ }
+ const description = providerActivityText(stringField(source, 'description'))
+ return description ? providerActivityText(`Working on: ${description}`) : null
+ }
+ if (kind === 'message:system:task_progress') {
+ return providerActivityText(
+ stringField(source, 'summary') ?? stringField(source, 'description')
+ )
+ }
+ if (kind === 'message:system:task_updated') {
+ return providerActivityText(stringField(record(source?.patch), 'description'))
+ }
+ if (kind === 'message:system:status') {
+ const status = stringField(source, 'status')
+ return status === 'compacting'
+ ? 'Compacting the conversation'
+ : status === 'requesting'
+ ? 'Requesting a response'
+ : null
+ }
+ if (kind === 'message:system:control_request_progress') {
+ const status = stringField(source, 'status')
+ return status === 'started'
+ ? 'Exploring a side question'
+ : status === 'api_retry'
+ ? 'Retrying a side question'
+ : null
+ }
+ if (kind === 'message:tool_progress') {
+ return null
+ }
+ return undefined
+}
+
+/** Retain only the current summary headline, never materialize the growing transcript. */
+export function createCodexProviderActivityReader(): (
+ method: string,
+ payload: unknown
+) => ActivityText {
+ let itemId: unknown
+ let summaryIndex: unknown
+ let headline = ''
+ let complete = false
+ const limit = MAX_PROVIDER_ACTIVITY_LENGTH * 2 + 16
+ return (method, payload) => {
+ if (
+ method !== 'item/reasoning/summaryTextDelta' &&
+ method !== 'item/reasoning/summaryPartAdded'
+ ) {
+ return codexProviderFrameActivity(method, payload)
+ }
+ const source = record(payload)
+ if (!stringField(source, 'itemId')) {
+ return undefined
+ }
+ if (
+ source?.itemId !== itemId ||
+ source?.summaryIndex !== summaryIndex ||
+ method === 'item/reasoning/summaryPartAdded'
+ ) {
+ itemId = source?.itemId
+ summaryIndex = source?.summaryIndex
+ headline = ''
+ complete = false
+ }
+ if (method === 'item/reasoning/summaryPartAdded') {
+ return null
+ }
+ if (complete || typeof source?.delta !== 'string') {
+ return undefined
+ }
+ headline += source.delta.slice(0, limit - headline.length)
+ const line = headline.trimStart().split(/\r?\n/, 1)[0]
+ complete =
+ headline.length === limit || /\r?\n/.test(headline.trimStart()) || /^\*\*.+\*\*/.test(line)
+ if (complete && line.startsWith('**') && !/\*\*.+\*\*/.test(line)) {
+ return providerActivityText(line.slice(2))
+ }
+ return codexProviderFrameActivity(method, payload, line)
+ }
+}
diff --git a/src/main/native-chat/agent-session-wire/provider-turn-activity-routing.test.ts b/src/main/native-chat/agent-session-wire/provider-turn-activity-routing.test.ts
new file mode 100644
index 00000000000..a66ac567a4d
--- /dev/null
+++ b/src/main/native-chat/agent-session-wire/provider-turn-activity-routing.test.ts
@@ -0,0 +1,267 @@
+import { describe, expect, it, vi } from 'vitest'
+import type {
+ AgentJournalItemBody,
+ AgentJournalItemIdentity
+} from '../../../shared/agent-session-journal-types'
+import type { AgentSessionTurnActivity } from '../../../shared/agent-session-wire'
+import { createClaudeJournalTranslator } from '../../claude/claude-structured-journal-translation'
+import { createCodexJournalTranslator } from '../../codex/codex-structured-journal-translation'
+import type { CodexStructuredSessionEvent } from '../../codex/codex-structured-session-state'
+import * as deltaCoalescer from './agent-session-delta-coalescer'
+import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
+
+const SESSION_ID = 'session-1'
+const THREAD_ID = 'thread-1'
+const TURN_ID = 'turn-1'
+
+function recordingSink() {
+ const rows: AgentJournalItemBody[] = []
+ const tombstones: AgentJournalItemIdentity[] = []
+ const activities: (AgentSessionTurnActivity | null)[] = []
+ const sink: StructuredAgentSessionEventSink = {
+ appendItem: (_identity, body) => rows.push(body),
+ appendTombstone: (identity) => tombstones.push(identity),
+ publish: vi.fn(),
+ setActivity: (activity) => activities.push(activity)
+ }
+ return { sink, rows, tombstones, activities }
+}
+
+function codexNotification(method: string, params: unknown): CodexStructuredSessionEvent {
+ return { type: 'notification', sessionId: SESSION_ID, threadId: THREAD_ID, method, params }
+}
+
+function claudeMessage(message: Record) {
+ return { type: 'message' as const, sessionId: SESSION_ID, message }
+}
+
+describe('provider turn activity routing', () => {
+ it('routes Codex activity without creating protocol rows', () => {
+ const state = recordingSink()
+ const translator = createCodexJournalTranslator({
+ sink: state.sink,
+ primaryThreadId: () => THREAD_ID,
+ schedule: () => () => {}
+ })
+ translator.handle(codexNotification('turn/started', { turn: { id: TURN_ID } }))
+ const lifecycleRows = state.rows.length
+
+ translator.handle(
+ codexNotification('item/mcpToolCall/progress', {
+ turnId: TURN_ID,
+ itemId: 'mcp-1',
+ message: 'Reading the issue context'
+ })
+ )
+ expect(state.rows).toHaveLength(lifecycleRows)
+ expect(state.activities.at(-1)).toEqual({
+ turnId: TURN_ID,
+ text: 'Reading the issue context'
+ })
+
+ translator.handle(
+ codexNotification('item/started', {
+ turnId: TURN_ID,
+ item: { type: 'reasoning', id: 'reasoning-1', summary: [], content: [] }
+ })
+ )
+ expect(state.rows).toHaveLength(lifecycleRows)
+ expect(state.activities.at(-1)?.text).toBe('Thinking through the request')
+
+ translator.handle(
+ codexNotification('item/reasoning/summaryPartAdded', {
+ turnId: TURN_ID,
+ itemId: 'reasoning-1',
+ summaryIndex: 0
+ })
+ )
+ expect(state.activities.at(-1)).toBeNull()
+ translator.handle(
+ codexNotification('item/reasoning/summaryTextDelta', {
+ turnId: TURN_ID,
+ itemId: 'reasoning-1',
+ summaryIndex: 0,
+ delta: 'Tracing the activity pipeline'
+ })
+ )
+ expect(state.rows).toHaveLength(lifecycleRows)
+ expect(state.activities.at(-1)?.text).toBe('Tracing the activity pipeline')
+ })
+
+ it('does not materialize full stream snapshots for activity on token deltas', () => {
+ const original = deltaCoalescer.createAgentSessionDeltaCoalescer
+ const snapshot = vi.fn()
+ const factory = vi
+ .spyOn(deltaCoalescer, 'createAgentSessionDeltaCoalescer')
+ .mockImplementation((deps) => {
+ const coalescer = original(deps)
+ return {
+ ...coalescer,
+ snapshot: (key) => {
+ snapshot()
+ return coalescer.snapshot(key)
+ }
+ }
+ })
+ try {
+ const state = recordingSink()
+ const translator = createCodexJournalTranslator({
+ sink: state.sink,
+ primaryThreadId: () => THREAD_ID,
+ schedule: () => () => {}
+ })
+ translator.handle(codexNotification('turn/started', { turn: { id: TURN_ID } }))
+ for (const method of [
+ 'item/agentMessage/delta',
+ 'item/commandExecution/outputDelta',
+ 'item/reasoning/summaryTextDelta'
+ ]) {
+ for (let index = 0; index < 100; index++) {
+ translator.handle(
+ codexNotification(method, {
+ turnId: TURN_ID,
+ itemId: method,
+ summaryIndex: 0,
+ delta: index === 0 ? '**Inspecting**\n' : 'more output'
+ })
+ )
+ }
+ }
+ expect(snapshot).not.toHaveBeenCalled()
+ translator.dispose()
+ } finally {
+ factory.mockRestore()
+ }
+ })
+
+ it('uses the newest summary part and stops republishing its body', () => {
+ const state = recordingSink()
+ const translator = createCodexJournalTranslator({
+ sink: state.sink,
+ primaryThreadId: () => THREAD_ID,
+ schedule: () => () => {}
+ })
+ translator.handle(codexNotification('turn/started', { turn: { id: TURN_ID } }))
+ const params = { turnId: TURN_ID, itemId: 'reasoning-1' }
+ for (const [summaryIndex, headline] of ['First headline', 'Newest headline'].entries()) {
+ translator.handle(
+ codexNotification('item/reasoning/summaryPartAdded', { ...params, summaryIndex })
+ )
+ translator.handle(
+ codexNotification('item/reasoning/summaryTextDelta', {
+ ...params,
+ summaryIndex,
+ delta: `**${headline}`
+ })
+ )
+ expect(state.activities.at(-1)).toBeNull()
+ translator.handle(
+ codexNotification('item/reasoning/summaryTextDelta', {
+ ...params,
+ summaryIndex,
+ delta: '**\n\nBody'
+ })
+ )
+ expect(state.activities.at(-1)?.text).toBe(headline)
+ }
+ const publications = state.activities.length
+ for (let index = 0; index < 100; index++) {
+ translator.handle(
+ codexNotification('item/reasoning/summaryTextDelta', {
+ ...params,
+ summaryIndex: 1,
+ delta: ' more body'
+ })
+ )
+ }
+ expect(state.activities).toHaveLength(publications)
+ translator.handle(
+ codexNotification('turn/completed', { turn: { id: TURN_ID, status: 'completed' } })
+ )
+ translator.handle(codexNotification('turn/started', { turn: { id: 'turn-2' } }))
+ translator.handle(
+ codexNotification('item/reasoning/summaryTextDelta', {
+ ...params,
+ turnId: 'turn-2',
+ summaryIndex: 1,
+ delta: '**Next turn**'
+ })
+ )
+ expect(state.activities.at(-1)).toEqual({ turnId: 'turn-2', text: 'Next turn' })
+ translator.dispose()
+ })
+
+ it('keeps Codex tool rows singular and the activity free of tool labels', () => {
+ const state = recordingSink()
+ const translator = createCodexJournalTranslator({
+ sink: state.sink,
+ primaryThreadId: () => THREAD_ID
+ })
+ translator.handle(codexNotification('turn/started', { turn: { id: TURN_ID } }))
+ const lifecycleRows = state.rows.length
+ translator.handle(
+ codexNotification('item/started', {
+ turnId: TURN_ID,
+ item: {
+ type: 'commandExecution',
+ id: 'command-1',
+ command: 'pnpm test',
+ status: 'inProgress'
+ }
+ })
+ )
+
+ expect(state.rows).toHaveLength(lifecycleRows + 1)
+ expect(state.rows.at(-1)).toMatchObject({ kind: 'tool-call', name: 'shell' })
+ expect(state.activities.at(-1)).toEqual({ turnId: TURN_ID, text: 'Running a command' })
+ expect(state.activities.at(-1)?.text).not.toContain('pnpm test')
+ })
+
+ it('routes Claude status frames without creating timeline rows and clears on settlement', () => {
+ const state = recordingSink()
+ const translator = createClaudeJournalTranslator({ sink: state.sink })
+ translator.handle({
+ ...claudeMessage({
+ type: 'user',
+ uuid: TURN_ID,
+ session_id: 'claude-session',
+ parent_tool_use_id: null,
+ message: { role: 'user', content: [{ type: 'text', text: 'Investigate activity' }] }
+ }),
+ startsTurn: true
+ })
+ const turnRows = state.rows.length
+
+ translator.handle(
+ claudeMessage({
+ type: 'system',
+ subtype: 'task_progress',
+ summary: 'Checking the renderer state'
+ })
+ )
+ translator.handle(claudeMessage({ type: 'system', subtype: 'status', status: 'compacting' }))
+ translator.handle(
+ claudeMessage({
+ type: 'system',
+ subtype: 'control_request_progress',
+ status: 'started'
+ })
+ )
+ expect(state.rows).toHaveLength(turnRows)
+ expect(state.activities.slice(-3)).toEqual([
+ { turnId: TURN_ID, text: 'Checking the renderer state' },
+ { turnId: TURN_ID, text: 'Compacting the conversation' },
+ { turnId: TURN_ID, text: 'Exploring a side question' }
+ ])
+
+ translator.handle(claudeMessage({ type: 'tool_progress', tool_name: 'SecretReader' }))
+ expect(state.rows).toHaveLength(turnRows)
+ expect(state.activities.at(-1)).toBeNull()
+
+ translator.handle(
+ claudeMessage({ type: 'result', subtype: 'success', is_error: false, result: 'Done' })
+ )
+ expect(state.activities.at(-1)).toBeNull()
+ expect(state.tombstones).toHaveLength(1)
+ })
+})
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts
index 226b9c1aab5..2ac8a5570f0 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts
@@ -60,6 +60,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
sessionId
) => this.owners.get(sessionId)?.backgroundTaskState?.(sessionId)
+ readCommands: NonNullable = (sessionId) =>
+ this.owners.get(sessionId)?.readCommands?.(sessionId)
+
answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) =>
this.owner(input.sessionId).answerPrompt(input)
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts
index e6f8e478695..4872426d009 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts
@@ -19,6 +19,7 @@ import type {
import type {
AgentSessionBackgroundTaskState,
AgentSessionOptionsResult,
+ AgentSessionSlashCommand,
AgentSessionWireRefusalCode
} from '../../../shared/agent-session-wire'
import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
@@ -143,6 +144,9 @@ export type StructuredAgentSessionAdapter = {
taskId?: string
}): Promise<{ cancelled: boolean }>
backgroundTaskState?(sessionId: string): AgentSessionBackgroundTaskState | null | undefined
+ /** The `/` surface the running provider reports for itself. Undefined when the
+ * provider never reports one, which is what keeps the client on its catalog. */
+ readCommands?(sessionId: string): AgentSessionSlashCommand[] | undefined
/** Fires the provider callback for an approval or a question. The wire calls
* this only after the durable compare-and-set won, so it runs exactly once. */
answerPrompt(input: {
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts
index 7113be8d54b..412aa88025d 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts
@@ -3,7 +3,10 @@
// Passing the host itself would let this quietly grow new dependencies; an explicit context makes
// each one a deliberate addition and keeps the orchestration testable without constructing a host.
-import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire'
+import type {
+ AgentSessionTurnActivity,
+ AgentSessionWireRefusal
+} from '../../../shared/agent-session-wire'
import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import type {
@@ -25,7 +28,11 @@ export type StructuredAgentSessionAttachContext = {
fence: number
) => void
snapshot: (sessionId: string, journal: AgentSessionJournal, fence: number) => void
- publish: (sessionId: string, journal: AgentSessionJournal) => void
+ publish: (
+ sessionId: string,
+ journal: AgentSessionJournal,
+ activity?: AgentSessionTurnActivity | null
+ ) => void
}
tasks: StructuredAgentSessionTaskQueue
reconcileLeases: (sessionId: string) => Promise
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts
index a22bbdcbb3e..16e5593d27c 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts
@@ -8,7 +8,8 @@
import { randomUUID } from 'node:crypto'
import type {
AgentSessionAttachResult,
- AgentSessionMutationResult
+ AgentSessionMutationResult,
+ AgentSessionTurnActivity
} from '../../../shared/agent-session-wire'
import type { AgentSessionAttachParams } from './structured-agent-session-attach'
import { performAttach } from './structured-agent-session-attach-flow'
@@ -96,8 +97,8 @@ export function attachStructuredAgentSession(
// Site 8: the provisional journal has no owner until the map takes it,
// and the barrier below throws by design.
try {
- await bindAndDrain(eventSink, attached.journal, fence, () =>
- context.subscribers.publish(sessionId, attached.journal)
+ await bindAndDrain(eventSink, attached.journal, fence, (activity) =>
+ context.subscribers.publish(sessionId, attached.journal, activity)
)
} catch (error) {
await agentSessionJournalCloseRetries.closeOrRetain(attached.journal)
@@ -148,7 +149,7 @@ async function bindAndDrain(
eventSink: DeferredStructuredAgentSessionEventSink,
journal: AgentSessionJournal,
fence: number,
- publish: () => void
+ publish: (activity?: AgentSessionTurnActivity | null) => void
): Promise {
eventSink.bind({ journal, fence, publish })
const barrier = await eventSink.drained()
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts
new file mode 100644
index 00000000000..705baf182fa
--- /dev/null
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts
@@ -0,0 +1,134 @@
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { expect, it, vi } from 'vitest'
+import type {
+ AgentSessionSlashCommand,
+ AgentSessionSubscribeEvent
+} from '../../../shared/agent-session-wire'
+import { createStructuredAgentSessionEventCoalescer } from '../../../shared/structured-agent-session-coalescer'
+import {
+ EMPTY_STRUCTURED_AGENT_SESSION,
+ reduceStructuredAgentSession
+} from '../../../shared/structured-agent-session-reducer'
+import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open'
+import { AgentSessionSubscribers } from './structured-agent-session-subscribers'
+import {
+ adapterFor,
+ fakeClaude,
+ identityFor,
+ PROVIDER_SESSION_ID
+} from '../../claude/claude-structured-session-test-support'
+
+it('publishes idle provider reloads only when the actual command catalog changes', async () => {
+ const claude = fakeClaude()
+ const adapter = adapterFor(claude)
+ const publish = vi.fn()
+ await adapter.acquire({
+ identity: identityFor(),
+ fence: 7,
+ spawnToken: 'commands',
+ events: {
+ appendItem: vi.fn(),
+ appendTombstone: vi.fn(),
+ publish
+ }
+ })
+ publish.mockClear()
+ const message = {
+ type: 'system',
+ subtype: 'commands_changed',
+ session_id: PROVIDER_SESSION_ID,
+ commands: [{ name: 'new-skill', description: '', argumentHint: '' }]
+ }
+ claude.connections[0].handlers.onMessage?.(message)
+ expect(adapter.readCommands(identityFor().sessionId)).toEqual([
+ { name: 'new-skill', kind: 'command', kindUnspecified: true }
+ ])
+ expect(publish).toHaveBeenCalledTimes(1)
+ claude.connections[0].handlers.onMessage?.(message)
+ expect(publish).toHaveBeenCalledTimes(1)
+})
+
+it('delivers catalog changes through existing frames without resending them on ordinary output', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-command-publication-'))
+ const journals = createTrackedJournalOpener()
+ const events: AgentSessionSubscribeEvent[] = []
+ let state = EMPTY_STRUCTURED_AGENT_SESSION
+ const coalescer = createStructuredAgentSessionEventCoalescer((event) => {
+ events.push(event)
+ state = reduceStructuredAgentSession(state, { type: 'event', event })
+ })
+ try {
+ const journal = await journals.open({ identity: identityFor(), journalDir: root })
+ const sessionId = identityFor().sessionId
+ let commands: AgentSessionSlashCommand[] | undefined = [
+ { name: 'loaded', kind: 'command', kindUnspecified: true }
+ ]
+ const subscribers = new AgentSessionSubscribers({ readCommands: () => commands })
+ const close = subscribers.open({
+ id: 'one',
+ sessionId,
+ journal,
+ fence: 7,
+ emit: coalescer.push
+ })
+ expect(state.commands).toEqual(commands)
+ for (let i = 0; i < 25; i++) {
+ subscribers.handoff(sessionId, 7, {
+ owner: 'none',
+ direction: null,
+ phase: 'idle',
+ stage: null,
+ operationId: null
+ })
+ }
+ coalescer.flush()
+ expect(events.filter((event) => 'commands' in event)).toHaveLength(1)
+ commands = []
+ subscribers.publish(sessionId, journal)
+ subscribers.handoff(sessionId, 7, {
+ owner: 'none',
+ direction: null,
+ phase: 'idle',
+ stage: null,
+ operationId: null
+ })
+ coalescer.flush()
+ expect(state.commands).toEqual([])
+ expect(events.filter((event) => 'commands' in event)).toHaveLength(2)
+ commands = undefined
+ subscribers.publish(sessionId, journal)
+ coalescer.flush()
+ expect(state.commands).toBeNull()
+ close()
+ commands = [{ name: 'reconnected', kind: 'skill' }]
+ subscribers.open({
+ id: 'two',
+ sessionId,
+ journal,
+ cursor: journal.cursor(),
+ fence: 8,
+ emit: coalescer.push
+ })
+ coalescer.flush()
+ expect(state.commands).toEqual(commands)
+ subscribers.reset(sessionId, journal, 'epoch_changed', 8)
+ expect(state.commands).toEqual(commands)
+ commands = undefined
+ subscribers.open({
+ id: 'three',
+ sessionId,
+ journal,
+ cursor: journal.cursor(),
+ fence: 9,
+ emit: coalescer.push
+ })
+ coalescer.flush()
+ expect(state.commands).toBeNull()
+ } finally {
+ coalescer.dispose()
+ await journals.closeAll()
+ await rm(root, { recursive: true, force: true })
+ }
+})
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts
index c97161ce3dd..d1b7ea533a1 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts
@@ -3,6 +3,7 @@ import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../../shared/agent-session-journal-types'
+import type { AgentSessionTurnActivity } from '../../../shared/agent-session-wire'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import {
createDeferredStructuredAgentSessionEventSink,
@@ -20,7 +21,13 @@ function identity(ordinal: number): AgentJournalItemIdentity {
return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal }
}
-type Recorded = { call: string; fence?: number; ordinal?: number; settlementId?: string }
+type Recorded = {
+ call: string
+ fence?: number
+ ordinal?: number
+ settlementId?: string
+ activity?: AgentSessionTurnActivity | null
+}
function target(
fence: number,
@@ -49,7 +56,12 @@ function target(
return { epoch: 'e', sequence: 0 }
})
} as unknown as AgentSessionJournal
- return { journal, fence, publish: () => log.push({ call: 'publish', fence }) }
+ return {
+ journal,
+ fence,
+ publish: (activity) =>
+ log.push({ call: 'publish', fence, ...(activity !== undefined ? { activity } : {}) })
+ }
}
describe('deferred structured agent-session event sink', () => {
@@ -317,4 +329,22 @@ describe('deferred structured agent-session event sink', () => {
{ call: 'appendItem', fence: 6, ordinal: 2 }
])
})
+
+ it('coalesces provider activity as a publication without a journal write', async () => {
+ const log: Recorded[] = []
+ const deferred = createDeferredStructuredAgentSessionEventSink()
+
+ deferred.sink.setActivity?.({ turnId: 'turn-1', text: 'Thinking' })
+ deferred.sink.setActivity?.({ turnId: 'turn-1', text: 'Checking the result' })
+ deferred.bind(target(6, log))
+ await deferred.drained()
+
+ expect(log).toEqual([
+ {
+ call: 'publish',
+ fence: 6,
+ activity: { turnId: 'turn-1', text: 'Checking the result' }
+ }
+ ])
+ })
})
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts
index 7e0192f179c..6952b6d93e6 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts
@@ -3,6 +3,7 @@ import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../../shared/agent-session-journal-types'
+import type { AgentSessionTurnActivity } from '../../../shared/agent-session-wire'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders'
import { estimateStructuredAgentSessionItemBytes } from './structured-agent-session-event-sink-estimate'
@@ -43,6 +44,7 @@ export type StructuredAgentSessionEventSink = {
options?: StructuredAgentSessionAppendOptions
): StructuredAgentSessionSinkAdmission
publish(options?: StructuredAgentSessionAppendOptions): void
+ setActivity?(activity: AgentSessionTurnActivity | null): void
tryAppendItem?(
identity: AgentJournalItemIdentity,
body: AgentJournalItemBody,
@@ -66,7 +68,7 @@ export type StructuredAgentSessionEventSink = {
export type StructuredAgentSessionEventTarget = {
journal: AgentSessionJournal
fence: number
- publish: () => void
+ publish: (activity?: AgentSessionTurnActivity | null) => void
}
export type DeferredStructuredAgentSessionEventSink = {
@@ -209,6 +211,13 @@ export function createDeferredStructuredAgentSessionEventSink(
publish: (options = {}) => {
publish(options)
},
+ setActivity: (activity) => {
+ queue.submit({
+ bytes: Buffer.byteLength(JSON.stringify(activity), 'utf8') + 64,
+ coalescingKey: 'turn-activity',
+ run: (bound) => bound.publish(activity)
+ })
+ },
tryPublish: publish
},
bind: (next) => queue.bind(next),
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts
index 586df1476cf..abd2268c809 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts
@@ -230,7 +230,7 @@ export async function acquireNativeHandoffOwner(
eventSink.bind({
journal: session.journal,
fence: proved.lease.runtimeFence,
- publish: () => host.subscribers.publish(input.sessionId, session.journal)
+ publish: (activity) => host.subscribers.publish(input.sessionId, session.journal, activity)
})
const acquiredBarrier = await eventSink.drained()
if (!acquiredBarrier.ok) {
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts
index f4a0244d0af..9a5f3e0c475 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts
@@ -5,7 +5,10 @@
// they share one path here rather than five copies in the host. The host keeps attach, holds and
// teardown; this is the surface that assumes those already happened.
-import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
+import type {
+ AgentJournalItemIdentity,
+ AgentJournalMessageItem
+} from '../../../shared/agent-session-journal-types'
import type {
AgentSessionCancelResult,
AgentSessionMutationEnvelope,
@@ -118,3 +121,26 @@ export function readStructuredAgentSessionOptions(
return context.deps.adapter.readOptions({ sessionId, fence: session.fence })
})
}
+
+/** Settle provider-proven delivery independently of an in-flight client mutation. */
+export async function settleStructuredAgentSessionLateDispatch(
+ context: StructuredAgentSessionMutationContext,
+ input: {
+ sessionId: string
+ clientMessageId: string
+ providerIdentity: AgentJournalItemIdentity
+ }
+): Promise {
+ const session = context.sessions.get(input.sessionId)
+ if (!session) {
+ return
+ }
+ // The journal queue drains before close; the host queue would defer this past teardown.
+ await session.journal.resolveDispatch({
+ clientMessageId: input.clientMessageId,
+ state: 'accepted',
+ providerIdentity: input.providerIdentity,
+ fence: session.fence
+ })
+ context.publish(input.sessionId, session.journal)
+}
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts
index 2b230db1357..b948ac08abd 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts
@@ -6,6 +6,7 @@
// with the global runtime reference already cleared — the one state from which
// nothing can ever close them.
+import { withTimeout } from '../../../shared/promise-timeout-fallback'
import { agentSessionJournalCloseRetries } from '../agent-session-journal/journal-close-retry'
import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types'
@@ -14,6 +15,39 @@ export type StructuredAgentSessionTeardownPhase = {
run: () => Promise | void
}
+/** Quit must not wait indefinitely on an in-flight handoff; see `drain-handoffs` below. */
+const HANDOFF_DRAIN_TIMEOUT_MS = 5_000
+
+/**
+ * The quit-path phase order, which is load-bearing rather than incidental.
+ *
+ * Handoffs drain BEFORE the session map is dropped: a flow left running writes rows into a
+ * journal this teardown is about to close, and publishes against a session it removed. That drain
+ * is bounded because a flow wedged in `launchTui` would otherwise hold the quit open forever;
+ * giving up merely restores the old orphaning, which the publish guard already makes survivable.
+ */
+export function structuredAgentSessionHostTeardownPhases(collaborators: {
+ holds: { dispose: () => Promise | void }
+ runtimeState: {
+ stopLeaseRenewal: () => void
+ flushAllEventSinks: () => Promise
+ }
+ handoffs: { stopTuiHistoryCatchup: () => void; drain: () => Promise }
+ tasks: { drainAttaches: () => Promise }
+}): StructuredAgentSessionTeardownPhase[] {
+ return [
+ { name: 'dispose-holds', run: () => collaborators.holds.dispose() },
+ { name: 'stop-lease-renewal', run: () => collaborators.runtimeState.stopLeaseRenewal() },
+ { name: 'stop-tui-catchup', run: () => collaborators.handoffs.stopTuiHistoryCatchup() },
+ {
+ name: 'drain-handoffs',
+ run: () => withTimeout(collaborators.handoffs.drain(), HANDOFF_DRAIN_TIMEOUT_MS, undefined)
+ },
+ { name: 'drain-attaches', run: () => collaborators.tasks.drainAttaches() },
+ { name: 'flush-event-sinks', run: () => collaborators.runtimeState.flushAllEventSinks() }
+ ]
+}
+
export async function tearDownStructuredAgentSessionHost(input: {
phases: readonly StructuredAgentSessionTeardownPhase[]
sessions: Map
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts
index ea4b594ac44..7a321668c46 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts
@@ -1,6 +1,7 @@
import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication'
import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
+import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { AgentSessionSpawnTokenScan } from '../../runtime/agent-session-spawn-token-process-scan'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
@@ -62,5 +63,11 @@ export type StructuredAgentSessionHostDeps = {
/** How long a session outlives its last surface. Tests drive this; production takes the default. */
releaseGraceMs?: number
onEventSinkError?: (input: { sessionId: string; error: unknown }) => void
+ /** Every status projection this host publishes. `replay` marks a re-projection of state the host
+ * already knew (restore, an arriving subscriber) rather than a fresh journal edge. */
+ onSessionStatusChanged?: (
+ summary: AgentSessionStatusSummary,
+ options: { replay: boolean }
+ ) => void
handoffTransport?: StructuredAgentSessionHandoffTransport
}
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
index 13b7d7b441e..898b5e2d4be 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
@@ -1,6 +1,7 @@
// Structured agent-session host: where the lease, journal, and provider adapter meet.
// Mutations share one durable admission path and serialize per session.
+import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types'
import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record'
import type * as SessionWire from '../../../shared/agent-session-wire'
import type { AgentSessionAttachParams } from './structured-agent-session-attach'
@@ -38,9 +39,13 @@ import {
respondToStructuredAgentSessionPrompt,
sendStructuredAgentSessionTurn,
setStructuredAgentSessionOption,
+ settleStructuredAgentSessionLateDispatch,
type StructuredAgentSessionMutationContext
} from './structured-agent-session-host-mutations'
-import { tearDownStructuredAgentSessionHost } from './structured-agent-session-host-teardown'
+import {
+ structuredAgentSessionHostTeardownPhases,
+ tearDownStructuredAgentSessionHost
+} from './structured-agent-session-host-teardown'
import type {
StructuredAgentSessionCaller,
StructuredAgentSessionHostDeps,
@@ -50,19 +55,18 @@ import type {
import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed'
import { StructuredAgentSessionEventRecovery } from './structured-agent-session-event-recovery'
import { StructuredAgentSessionBackgroundTaskChannel } from './structured-agent-session-background-task-channel'
-import { withTimeout } from '../../../shared/promise-timeout-fallback'
export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
-/** Quit must not wait indefinitely on an in-flight handoff; see the drain phase below. */
-const HANDOFF_DRAIN_TIMEOUT_MS = 5_000
export class StructuredAgentSessionHost {
private readonly sessions = new Map()
private readonly statusFeed = new StructuredAgentSessionStatusFeed({
sessions: this.sessions,
getRecord: (sessionId) => this.deps.store.getRecord(sessionId),
- now: () => this.now()
+ now: () => this.now(),
+ onStatusChanged: (summary, options) => this.deps.onSessionStatusChanged?.(summary, options)
})
private readonly subscribers = new AgentSessionSubscribers({
+ readCommands: (sessionId) => this.deps.adapter.readCommands?.(sessionId),
onJournalPublished: (sessionId, journal) => this.statusFeed.publish(sessionId, journal)
})
private readonly tasks = new StructuredAgentSessionTaskQueue()
@@ -128,7 +132,7 @@ export class StructuredAgentSessionHost {
// `hasSession` inside the same serialized step as this `set`.
onReadable: (sessionId, restored) => {
this.sessions.set(sessionId, restored)
- this.statusFeed.publish(sessionId)
+ this.statusFeed.publish(sessionId, undefined, { replay: true })
},
restoreHandoff: (sessionId) => this.handoffs.restore(sessionId)
})
@@ -179,14 +183,11 @@ export class StructuredAgentSessionHost {
/** The host's half of attaching, named so it cannot grow dependencies unnoticed. */
private attachContext(): StructuredAgentSessionAttachContext {
return {
- deps: this.deps,
- runtimeState: this.runtimeState,
- sessions: this.sessions,
+ ...this.lifetimeContext(),
subscribers: this.subscribers,
tasks: this.tasks,
reconcileLeases: (sessionId) => this.reconcileLeases(sessionId),
- serialize: (sessionId, task) => this.serialize(sessionId, task),
- now: () => this.now()
+ serialize: (sessionId, task) => this.serialize(sessionId, task)
}
}
/** Releases a session's resources without ending the conversation: the record and journal stay
@@ -253,22 +254,12 @@ export class StructuredAgentSessionHost {
async flushAllStreamedEvents(): Promise {
await tearDownStructuredAgentSessionHost({
- phases: [
- { name: 'dispose-holds', run: () => this.holds.dispose() },
- { name: 'stop-lease-renewal', run: () => this.runtimeState.stopLeaseRenewal() },
- { name: 'stop-tui-catchup', run: () => this.handoffs.stopTuiHistoryCatchup() },
- // Before the session map is dropped: a handoff flow left running writes rows into a
- // journal this teardown is about to close, and publishes against a session it removed.
- // Why bounded: this phase is on the app-quit path, and a flow wedged in `launchTui` would
- // otherwise hold the quit open forever. Giving up merely restores the old orphaning, which
- // the publish guard above already makes survivable.
- {
- name: 'drain-handoffs',
- run: () => withTimeout(this.handoffs.drain(), HANDOFF_DRAIN_TIMEOUT_MS, undefined)
- },
- { name: 'drain-attaches', run: () => this.tasks.drainAttaches() },
- { name: 'flush-event-sinks', run: () => this.runtimeState.flushAllEventSinks() }
- ],
+ phases: structuredAgentSessionHostTeardownPhases({
+ holds: this.holds,
+ runtimeState: this.runtimeState,
+ handoffs: this.handoffs,
+ tasks: this.tasks
+ }),
sessions: this.sessions
})
}
@@ -317,6 +308,11 @@ export class StructuredAgentSessionHost {
readOptions = (sessionId: string): Promise =>
readStructuredAgentSessionOptions(this.mutationContext(), sessionId)
+ /** Undefined means unavailable; an empty array is an authoritative catalog. */
+ readCommands = (sessionId: string): SessionWire.AgentSessionCommandsResult => ({
+ commands: this.deps.adapter.readCommands?.(sessionId)
+ })
+
async handoffStatus(sessionId: string): Promise {
this.requireSession(sessionId)
return this.serialize(sessionId, () =>
@@ -324,23 +320,27 @@ export class StructuredAgentSessionHost {
)
}
- history = (
- request: SessionWire.AgentSessionHistoryRequest
- ): SessionWire.AgentSessionHistoryResult => this.backgroundTasks.history(request)
+ history: StructuredAgentSessionBackgroundTaskChannel['history'] = (request) =>
+ this.backgroundTasks.history(request)
+
+ /** The fully reduced timeline, for readers that cannot tolerate a page's ambiguity — a settled
+ * turn is tombstoned, so an item's ABSENCE from a bounded page proves nothing. */
+ journalSnapshot = (sessionId: string): AgentJournalSnapshot =>
+ this.requireSession(sessionId).journal.snapshot()
subscribe = (input: AgentSessionSubscribeInput): (() => void) =>
this.backgroundTasks.subscribe(input)
- publishBackgroundTaskState: StructuredAgentSessionBackgroundTaskChannel['publish'] = (
- sessionId,
- state
- ) => this.backgroundTasks.publish(sessionId, state)
+ settleLateDispatch = (input: Parameters[1]) =>
+ settleStructuredAgentSessionLateDispatch(this.mutationContext(), input)
+
+ publishBackgroundTaskState: StructuredAgentSessionBackgroundTaskChannel['publish'] = (...args) =>
+ this.backgroundTasks.publish(...args)
unsubscribe = (sessionId: string, id: string): void => this.subscribers.close(sessionId, id)
/** Every session's projected status for session lists; unlike `subscribe`, retains nothing. */
- subscribeStatus = (
- subscriber: Parameters[0]
- ): (() => void) => this.statusFeed.subscribe(subscriber)
+ subscribeStatus: StructuredAgentSessionStatusFeed['subscribe'] = (subscriber) =>
+ this.statusFeed.subscribe(subscriber)
private requireSession(sessionId: string): StructuredAgentSessionHostSession {
const session = this.sessions.get(sessionId)
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts
new file mode 100644
index 00000000000..a75d2ea6512
--- /dev/null
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts
@@ -0,0 +1,224 @@
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
+import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
+import type {
+ AgentSessionMutationEnvelope,
+ AgentSessionSubscribeEvent
+} from '../../../shared/agent-session-wire'
+import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
+import type {
+ AgentSessionDispatchOutcome,
+ StructuredAgentSessionAdapter
+} from './structured-agent-session-adapter'
+import { StructuredAgentSessionHost } from './structured-agent-session-host'
+import {
+ HOST_TEST_NOW as NOW,
+ HOST_TEST_SESSION as SESSION,
+ HOST_TEST_THREAD as THREAD,
+ hostTestAttachParams,
+ hostTestMessage,
+ hostTestOperationId,
+ resetHostTestOperationIds
+} from './structured-agent-session-host-test-data'
+
+const CALLER = { callerKey: 'client-1' }
+
+let root: string
+let store: AgentSessionRecordStore
+let host: StructuredAgentSessionHost
+let dispatch: Mock
+let closeSession: Mock>
+
+function accepted(): AgentSessionDispatchOutcome {
+ return {
+ state: 'accepted',
+ providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }
+ }
+}
+
+function sendParams(text: string): {
+ envelope: AgentSessionMutationEnvelope
+ body: ReturnType
+} {
+ const body = hostTestMessage(text)
+ return {
+ envelope: {
+ sessionId: SESSION,
+ clientOperationId: hostTestOperationId(),
+ expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1,
+ payloadFingerprint: computeAgentSessionPayloadFingerprint({
+ method: 'agentSession.send',
+ sessionId: SESSION,
+ fields: { body }
+ })
+ },
+ body
+ }
+}
+
+function submissions(): unknown {
+ const state = host.history({ sessionId: SESSION, direction: 'tail' })
+ return state.ok ? state.page.submissions : null
+}
+
+beforeEach(async () => {
+ root = await mkdtemp(join(tmpdir(), 'orca-wire-late-settle-'))
+ resetHostTestOperationIds()
+ dispatch = vi.fn(async () => accepted())
+ closeSession = vi.fn(async () => true)
+ store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' })
+ host = new StructuredAgentSessionHost({
+ store,
+ adapter: {
+ acquire: vi.fn(async ({ fence }) => ({
+ process: {
+ hostId: 'local',
+ pid: 4242,
+ processStartTimeMs: 1_700_000_000_000,
+ spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a'
+ },
+ link: {
+ linkId: `link-${fence}`,
+ handle: { provider: 'codex' as const, threadId: THREAD },
+ origin: 'created' as const,
+ mintedAtFence: fence,
+ observedAt: NOW
+ }
+ })),
+ releaseAcquisition: vi.fn(async () => true),
+ dispatch,
+ closeSession,
+ cancelTurn: vi.fn(async () => ({ cancelled: true })),
+ answerPrompt: vi.fn(async () => undefined),
+ setOption: vi.fn(async () => undefined)
+ },
+ journalRoot: root,
+ claimKeyId: 'key-1',
+ mintSpawnToken: () => 'spawn-a',
+ now: () => NOW
+ })
+ expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true)
+})
+
+afterEach(async () => {
+ await host.flushAllStreamedEvents()
+ await host.close(SESSION)
+ await rm(root, { recursive: true, force: true })
+})
+
+describe('settling a send the provider proves it received after the ack window', () => {
+ it('publishes acceptance during a pending send and never reopens it for retry', async () => {
+ let finishDispatch!: (outcome: AgentSessionDispatchOutcome) => void
+ dispatch.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finishDispatch = resolve
+ })
+ )
+ const events: AgentSessionSubscribeEvent[] = []
+ const unsubscribe = host.subscribe({
+ id: 'late-receipt',
+ sessionId: SESSION,
+ emit: (event) => events.push(event)
+ })
+ const params = sendParams('echo before send completes')
+ const pending = host.send(CALLER, params)
+ await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1))
+ try {
+ await host.settleLateDispatch({
+ sessionId: SESSION,
+ clientMessageId: params.envelope.clientOperationId,
+ providerIdentity: { provider: 'claude', sessionId: THREAD, uuid: 'early-echo' }
+ })
+ expect(events.at(-1)).toMatchObject({
+ type: 'batch',
+ batch: {
+ submissions: [
+ { clientMessageId: params.envelope.clientOperationId, dispatchState: 'accepted' }
+ ]
+ }
+ })
+ } finally {
+ finishDispatch({ state: 'unknown', reason: 'ack timeout' })
+ unsubscribe()
+ }
+ await expect(pending).resolves.toMatchObject({
+ ok: true,
+ value: { submission: { dispatchState: 'accepted' } }
+ })
+ await expect(host.send(CALLER, { ...params, retryUnknown: true })).resolves.toMatchObject({
+ ok: true,
+ value: { submission: { dispatchState: 'accepted' } }
+ })
+ expect(dispatch).toHaveBeenCalledTimes(1)
+ })
+
+ it('persists an echo received while the provider is closing', async () => {
+ dispatch.mockResolvedValueOnce({ state: 'unknown', reason: 'ack timeout' })
+ const params = sendParams('received just before shutdown')
+ await host.send(CALLER, params)
+ let settlement: Promise | undefined
+ closeSession.mockImplementationOnce(async () => {
+ settlement = host.settleLateDispatch({
+ sessionId: SESSION,
+ clientMessageId: params.envelope.clientOperationId,
+ providerIdentity: { provider: 'claude', sessionId: THREAD, uuid: 'closing-echo' }
+ })
+ void settlement.catch(() => undefined)
+ return true
+ })
+
+ await host.close(SESSION)
+ await expect(settlement).resolves.toBeUndefined()
+ await host.revealSession(SESSION)
+ expect(submissions()).toMatchObject([{ dispatchState: 'accepted' }])
+ expect(dispatch).toHaveBeenCalledTimes(1)
+ })
+
+ it('moves a durable unknown to accepted so nothing offers to send it again', async () => {
+ dispatch.mockRejectedValueOnce(new Error('socket closed'))
+ const params = sendParams('sent while a turn was running')
+ const first = await host.send(CALLER, params)
+ expect(first).toMatchObject({ ok: true, value: { submission: { dispatchState: 'unknown' } } })
+
+ await host.settleLateDispatch({
+ sessionId: SESSION,
+ clientMessageId: params.envelope.clientOperationId,
+ providerIdentity: { provider: 'claude', sessionId: THREAD, uuid: 'late-uuid' }
+ })
+
+ expect(submissions()).toMatchObject([
+ { clientMessageId: params.envelope.clientOperationId, dispatchState: 'accepted' }
+ ])
+ // The point of the fix: the client stops rendering Retry, and Retry is what
+ // was delivering the message to the agent a second time.
+ expect(dispatch).toHaveBeenCalledTimes(1)
+ })
+
+ it('leaves an already accepted send alone', async () => {
+ const params = sendParams('ordinary send')
+ await host.send(CALLER, params)
+
+ await host.settleLateDispatch({
+ sessionId: SESSION,
+ clientMessageId: params.envelope.clientOperationId,
+ providerIdentity: { provider: 'claude', sessionId: THREAD, uuid: 'a-different-uuid' }
+ })
+
+ expect(submissions()).toMatchObject([
+ { clientMessageId: params.envelope.clientOperationId, dispatchState: 'accepted' }
+ ])
+ })
+
+ it('ignores a session this host is not holding', async () => {
+ await expect(
+ host.settleLateDispatch({
+ sessionId: 'session-that-is-not-attached',
+ clientMessageId: 'whatever',
+ providerIdentity: { provider: 'claude', sessionId: THREAD, uuid: 'x' }
+ })
+ ).resolves.toBeUndefined()
+ })
+})
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts
index 41054d10ece..5b4f0556915 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts
@@ -3,7 +3,10 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
-import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire'
+import type {
+ AgentSessionMutationEnvelope,
+ AgentSessionStatusEvent
+} from '../../../shared/agent-session-wire'
import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import { AgentSessionOptionRejectedError } from './structured-agent-session-option-error'
@@ -212,6 +215,39 @@ afterEach(async () => {
})
describe('structured session options and close', () => {
+ it('publishes an acknowledged model without waiting for journal traffic', async () => {
+ const body = {
+ kind: 'message' as const,
+ role: 'user' as const,
+ blocks: [{ type: 'text' as const, text: 'first task' }]
+ }
+ await host.send(CALLER, {
+ envelope: envelope('agentSession.send', { body }),
+ body
+ })
+ const events: AgentSessionStatusEvent[] = []
+ host.subscribeStatus({ id: 'session-list', emit: (event) => events.push(event) })
+ expect(events).toEqual([
+ {
+ type: 'snapshot',
+ sessions: [expect.objectContaining({ status: 'idle', model: DEFAULT_MODEL })]
+ }
+ ])
+ const fields = { key: 'model', value: PICKED_MODEL }
+
+ await host.setOption(CALLER, {
+ envelope: envelope('agentSession.setOption', fields),
+ ...fields
+ })
+
+ expect(events.slice(1)).toEqual([
+ {
+ type: 'status',
+ session: expect.objectContaining({ sessionId: SESSION, model: PICKED_MODEL })
+ }
+ ])
+ })
+
it('settles a pre-mutation rejection so a fresh retry can succeed', async () => {
optionFailure = new AgentSessionOptionRejectedError('model list unavailable')
const fields = { key: 'model', value: PICKED_MODEL }
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts
index 7efd147c420..c1b52f879d4 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts
@@ -2,9 +2,16 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire'
+import { createClaudeJournalTranslator } from '../../claude/claude-structured-journal-translation'
+import { publishCodexTurnLifecycle } from '../../codex/codex-structured-journal-translation-turns'
+import { createDeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open'
-import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed'
+import {
+ StructuredAgentSessionStatusFeed,
+ type StructuredAgentSessionStatusFeedDeps
+} from './structured-agent-session-status-feed'
const SESSION = 'status-session'
const TURN_IDENTITY = {
@@ -32,7 +39,7 @@ afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
-async function openJournal(sessionId = SESSION) {
+async function openJournal(sessionId = SESSION, now?: () => number) {
return journals.open({
identity: {
sessionId,
@@ -41,6 +48,7 @@ async function openJournal(sessionId = SESSION) {
agent: 'codex',
providerHandle: { kind: 'codex', threadId: 'thread-1' }
},
+ now,
journalDir: join(root, sessionId)
})
}
@@ -52,9 +60,14 @@ function indexed(session: { journal: Awaited> })
}
}
-function feedFor(sessions: Map> }>) {
+function feedFor(
+ sessions: Map> }>,
+ record: Partial | null = null,
+ onStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged']
+) {
let now = 1_000
const feed = new StructuredAgentSessionStatusFeed({
+ ...(onStatusChanged ? { onStatusChanged } : {}),
sessions: {
get: (sessionId: string) => {
const session = sessions.get(sessionId)
@@ -66,7 +79,7 @@ function feedFor(sessions: Map>,
- getRecord: () => null,
+ getRecord: () => record as AgentSessionRecord | null,
now: () => (now += 1)
})
const events: AgentSessionStatusEvent[] = []
@@ -132,6 +145,145 @@ describe('StructuredAgentSessionStatusFeed', () => {
expect(events).toHaveLength(3)
})
+ it('preserves the completion tombstone time when the journal and host reopen', async () => {
+ let now = 100
+ const journal = await openJournal(SESSION, () => now)
+ await journal.appendItem(
+ USER_IDENTITY,
+ { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] },
+ { fence: 1 }
+ )
+ await journal.appendItem(
+ TURN_IDENTITY,
+ { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } },
+ { fence: 1 }
+ )
+ const { feed, events } = feedFor(new Map([[SESSION, { journal }]]))
+ now = 200
+ await journal.appendTombstone(TURN_IDENTITY, { fence: 1 })
+ feed.publish(SESSION)
+ expect(events.at(-1)).toMatchObject({
+ type: 'status',
+ session: { status: 'idle', updatedAt: 200 }
+ })
+ await journal.close()
+ now = 900
+ const reopened = await openJournal(SESSION, () => now)
+ const restored = feedFor(new Map([[SESSION, { journal: reopened }]]))
+ expect(restored.events[0]).toMatchObject({
+ type: 'snapshot',
+ sessions: [{ status: 'idle', updatedAt: 200 }]
+ })
+ })
+
+ it('publishes settled activity revisions and restores the same age after reopening', async () => {
+ let now = 100
+ const journal = await openJournal(SESSION, () => now)
+ await journal.appendItem(
+ USER_IDENTITY,
+ { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] },
+ { fence: 1 }
+ )
+ const assistant = { ...USER_IDENTITY, ordinal: 2 }
+ await journal.appendItem(
+ assistant,
+ { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'first' }] },
+ { fence: 1 }
+ )
+ const { feed, events } = feedFor(new Map([[SESSION, { journal }]]))
+ now = 200
+ await journal.appendItem(
+ assistant,
+ { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'finished' }] },
+ { fence: 1 }
+ )
+ feed.publish(SESSION)
+ expect(events.at(-1)).toMatchObject({
+ type: 'status',
+ session: { status: 'idle', updatedAt: 200 }
+ })
+ feed.publish(SESSION)
+ expect(events).toHaveLength(2)
+ await journal.close()
+ const reopened = await openJournal(SESSION, () => 900)
+ const restored = feedFor(new Map([[SESSION, { journal: reopened }]]))
+ expect(restored.events[0]).toMatchObject({
+ type: 'snapshot',
+ sessions: [{ status: 'idle', updatedAt: 200 }]
+ })
+ })
+
+ it('does not publish timestamp-only revisions while a turn is working', async () => {
+ let now = 100
+ const journal = await openJournal(SESSION, () => now)
+ await journal.appendItem(
+ USER_IDENTITY,
+ { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] },
+ { fence: 1 }
+ )
+ await journal.appendItem(
+ TURN_IDENTITY,
+ { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } },
+ { fence: 1 }
+ )
+ const { feed, events } = feedFor(new Map([[SESSION, { journal }]]))
+ for (let revision = 1; revision <= 20; revision += 1) {
+ now += 1
+ await journal.appendItem(
+ TURN_IDENTITY,
+ { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } },
+ { fence: 1 }
+ )
+ feed.publish(SESSION)
+ }
+ expect(events).toHaveLength(1)
+ now = 200
+ await journal.appendTombstone(TURN_IDENTITY, { fence: 1 })
+ feed.publish(SESSION)
+ expect(events).toHaveLength(2)
+ expect(events.at(-1)).toMatchObject({
+ type: 'status',
+ session: { status: 'idle', updatedAt: 200 }
+ })
+ })
+
+ it('carries the record model and the running tool line the sidebar row shows', async () => {
+ const journal = await openJournal()
+ const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), {
+ options: { model: 'gpt-5-codex' },
+ providerHandleChain: []
+ })
+ await journal.appendItem(
+ USER_IDENTITY,
+ { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'run the tests' }] },
+ { fence: 1 }
+ )
+ await journal.appendItem(
+ TURN_IDENTITY,
+ { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } },
+ { fence: 1 }
+ )
+ feed.publish(SESSION)
+ expect(events.at(-1)).toEqual({
+ type: 'status',
+ session: expect.objectContaining({ status: 'working', model: 'gpt-5-codex' })
+ })
+
+ await journal.appendItem(
+ { ...USER_IDENTITY, ordinal: 2 },
+ { kind: 'tool-call', name: 'shell', input: { command: 'pnpm test' }, state: 'running' },
+ { fence: 1 }
+ )
+ feed.publish(SESSION)
+
+ // A tool boundary changes nothing else about the session, so only comparing the new
+ // fields keeps it from being deduped away as an unchanged projection.
+ expect(events.at(-1)).toEqual({
+ type: 'status',
+ session: expect.objectContaining({ toolName: 'shell', toolInput: 'pnpm test' })
+ })
+ })
+
it('reports a pending approval as attention', async () => {
const journal = await openJournal()
const { feed, events } = feedFor(new Map([[SESSION, { journal }]]))
@@ -239,4 +391,135 @@ describe('StructuredAgentSessionStatusFeed', () => {
session: expect.objectContaining({ status: 'idle' })
})
})
+ it('reports each projection change to the host observer, marking re-projections as replay', async () => {
+ const journal = await openJournal()
+ const seen: { status: string | null; prompt: string; replay: boolean }[] = []
+ const { feed } = feedFor(new Map([[SESSION, { journal }]]), null, (summary, options) =>
+ seen.push({ status: summary.status, prompt: summary.latestPrompt, replay: options.replay })
+ )
+ await journal.appendItem(
+ USER_IDENTITY,
+ { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fix the auth bug' }] },
+ { fence: 1 }
+ )
+ await journal.appendItem(
+ TURN_IDENTITY,
+ { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } },
+ { fence: 1 }
+ )
+
+ feed.publish(SESSION, journal)
+ // A second identical publication is deduped, so the observer only ever sees changes.
+ feed.publish(SESSION, journal)
+ // seen[0] is the opening projection the harness's own subscriber triggered.
+ expect(seen.slice(1)).toEqual([
+ { status: 'working', prompt: 'fix the auth bug', replay: false }
+ ])
+
+ // An arriving subscriber re-projects state the host already knew.
+ await journal.appendTombstone(TURN_IDENTITY, { fence: 1 })
+ feed.subscribe({ id: 'list-2', emit: () => undefined })
+ expect(seen.at(-1)).toEqual({ status: 'idle', prompt: 'fix the auth bug', replay: true })
+ })
+
+ it.each(['claude', 'codex'] as const)(
+ 'observes a fast %s turn even when start and finish queue before persistence',
+ async (agent) => {
+ const journal = await openJournal()
+ await journal.appendItem(
+ USER_IDENTITY,
+ {
+ kind: 'message',
+ role: 'user',
+ blocks: [{ type: 'text', text: 'Fix auth' }]
+ },
+ { fence: 1 }
+ )
+ const seen: (string | null)[] = []
+ const { feed } = feedFor(new Map([[SESSION, { journal }]]), null, (summary) =>
+ seen.push(summary.status)
+ )
+ const deferred = createDeferredStructuredAgentSessionEventSink()
+ if (agent === 'claude') {
+ const translator = createClaudeJournalTranslator({ sink: deferred.sink })
+ translator.handle({
+ type: 'message',
+ sessionId: SESSION,
+ startsTurn: true,
+ message: {
+ type: 'user',
+ uuid: 'prompt-1',
+ session_id: 'claude-session',
+ parent_tool_use_id: null,
+ message: { role: 'user', content: [{ type: 'text', text: 'Fix auth' }] }
+ }
+ })
+ translator.handle({
+ type: 'message',
+ sessionId: SESSION,
+ message: {
+ type: 'result',
+ subtype: 'success',
+ session_id: 'claude-session',
+ uuid: 'result-1',
+ result: 'Done'
+ }
+ })
+ translator.dispose()
+ } else {
+ for (const state of ['running', 'completed'] as const) {
+ publishCodexTurnLifecycle({
+ sink: deferred.sink,
+ primaryThreadId: 'thread-1',
+ sessionId: SESSION,
+ threadId: 'thread-1',
+ turnId: 'turn-1',
+ state
+ })
+ }
+ }
+ for (let index = 0; index < 100; index++) {
+ deferred.sink.publish()
+ }
+ // This queue is also reached while a previous asynchronous journal write is pending.
+ let publications = 0
+ let activityPublications = 0
+ deferred.bind({
+ journal,
+ fence: 1,
+ publish: (activity) => {
+ if (activity === undefined) {
+ publications += 1
+ } else {
+ activityPublications += 1
+ }
+ feed.publish(SESSION, journal)
+ }
+ })
+ expect(await deferred.drained()).toEqual({ ok: true })
+ expect(seen).toEqual(['idle', 'working', 'idle'])
+ expect(publications).toBe(2)
+ expect(activityPublications).toBe(agent === 'claude' ? 1 : 0)
+ expect(deferred.state()).toMatchObject({ queuedBytes: 0, queuedOperations: 0 })
+ deferred.close()
+ }
+ )
+
+ it('keeps publishing to subscribers when the host observer throws', async () => {
+ const journal = await openJournal()
+ const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), null, () => {
+ throw new Error('observer exploded')
+ })
+ await journal.appendItem(
+ USER_IDENTITY,
+ { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] },
+ { fence: 1 }
+ )
+
+ expect(() => feed.publish(SESSION, journal)).not.toThrow()
+ expect(events.at(-1)).toEqual({
+ type: 'status',
+ session: expect.objectContaining({ status: 'idle', latestPrompt: 'hello' })
+ })
+ })
})
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts
index 5ad494cf830..348b5aebc21 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts
@@ -12,6 +12,8 @@
import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
+import { normalizeOptionalField } from '../../../shared/agent-status-field-normalization'
+import { AGENT_MODEL_MAX_LENGTH } from '../../../shared/agent-status-types'
import type {
AgentSessionStatusEvent,
AgentSessionStatusSummary
@@ -34,6 +36,9 @@ export type StructuredAgentSessionStatusFeedDeps = {
sessions: ReadonlyMap
getRecord: (sessionId: string) => AgentSessionRecord | null
now: () => number
+ /** Every projection change, whether or not anyone is subscribed. `replay` marks a re-projection
+ * of state the host already knew (restore, an arriving subscriber) rather than a journal edge. */
+ onStatusChanged?: (summary: AgentSessionStatusSummary, options: { replay: boolean }) => void
}
function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSummary): boolean {
@@ -41,7 +46,13 @@ function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSumma
a.workspaceId === b.workspaceId &&
a.agent === b.agent &&
a.status === b.status &&
+ // Settled activity changes ranking; streaming active turns must stay quiet.
+ (a.status !== 'idle' || a.updatedAt === b.updatedAt) &&
a.latestPrompt === b.latestPrompt &&
+ a.model === b.model &&
+ a.toolName === b.toolName &&
+ a.toolInput === b.toolInput &&
+ a.lastAssistantMessage === b.lastAssistantMessage &&
agentProviderSessionsEqual(undefined, a.providerSession, b.providerSession)
)
}
@@ -57,7 +68,7 @@ export class StructuredAgentSessionStatusFeed {
// Re-project before registering: a change found here has to reach the subscribers that
// already read the old value, and the arriving one carries it in its snapshot instead.
for (const [sessionId] of this.deps.sessions) {
- this.publish(sessionId)
+ this.publish(sessionId, undefined, { replay: true })
}
this.subscribers.set(subscriber.id, subscriber)
this.emit(subscriber, { type: 'snapshot', sessions: [...this.published.values()] })
@@ -78,7 +89,7 @@ export class StructuredAgentSessionStatusFeed {
}
/** Re-projects one session after its journal changed; equal projections are not re-sent. */
- publish(sessionId: string, journal?: AgentSessionJournal): void {
+ publish(sessionId: string, journal?: AgentSessionJournal, options?: { replay?: boolean }): void {
const session = this.deps.sessions.get(sessionId)
if (!session) {
return
@@ -90,6 +101,12 @@ export class StructuredAgentSessionStatusFeed {
}
this.published.set(sessionId, summary)
this.broadcast({ type: 'status', session: summary })
+ try {
+ this.deps.onStatusChanged?.(summary, { replay: options?.replay === true })
+ } catch (error) {
+ // An observer must never cost the subscribers their status event.
+ console.warn('[structured-session-status] status observer failed', error)
+ }
}
private summaryFor(
@@ -99,16 +116,19 @@ export class StructuredAgentSessionStatusFeed {
): AgentSessionStatusSummary {
// An unreadable journal projects as "no turn": the chat itself shows the reset.
const items = journal.isReadOnly ? [] : journal.snapshot().items
- const providerSession = structuredAgentSessionProviderSessionMetadata(
- this.deps.getRecord(sessionId)
- )
+ const record = this.deps.getRecord(sessionId)
+ const providerSession = structuredAgentSessionProviderSessionMetadata(record)
+ // The journal has no model: the record's acknowledged options are where an owner
+ // handoff or a mid-session switch lands, so the row follows whichever is in force.
+ const model = normalizeOptionalField(record?.options?.model, AGENT_MODEL_MAX_LENGTH)
return {
sessionId,
workspaceId: session.params.location.workspaceId,
agent: session.params.provider,
...projectStructuredAgentSessionStatusSummary(items),
+ ...(model ? { model } : {}),
...(providerSession ? { providerSession } : {}),
- updatedAt: this.deps.now()
+ updatedAt: journal.lastActivityAt() || this.deps.now()
}
}
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts
index 81bcfa82b40..a32db8786d4 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts
@@ -67,11 +67,54 @@ describe('AgentSessionSubscribers', () => {
removedItemIds: [],
submissions: []
},
- fence: 7
+ fence: 7,
+ activity: null
}
])
})
+ it('includes catalogs on reconnect and sends an idle checkpoint without journal work', async () => {
+ const journal = await journals.open({
+ identity: {
+ sessionId: SESSION,
+ workspaceId: 'workspace-1',
+ hostId: 'local',
+ agent: 'codex',
+ providerHandle: { kind: 'codex', threadId: 'thread-1' }
+ },
+ journalDir: join(root, 'catalog-journal')
+ })
+ let commands = [{ name: 'first', kind: 'skill' as const }]
+ const events: AgentSessionSubscribeEvent[] = []
+ const subscribers = new AgentSessionSubscribers({ readCommands: () => commands })
+ subscribers.open({
+ id: 'one',
+ sessionId: SESSION,
+ journal,
+ fence: 7,
+ emit: (event) => events.push(event)
+ })
+ expect(events[0]).toMatchObject({ type: 'snapshot', commands })
+ commands = [{ name: 'second', kind: 'skill' as const }]
+ subscribers.publish(SESSION, journal)
+ expect(events[1]).toEqual({
+ type: 'batch',
+ sessionId: SESSION,
+ fence: 7,
+ commands,
+ batch: { cursor: journal.cursor(), items: [], removedItemIds: [], submissions: [] }
+ })
+ subscribers.open({
+ id: 'two',
+ sessionId: SESSION,
+ journal,
+ cursor: journal.cursor(),
+ fence: 7,
+ emit: (event) => events.push(event)
+ })
+ expect(events[2]).toMatchObject({ type: 'batch', commands })
+ })
+
it('reports every content publication to the journal hook, subscribed or not', async () => {
const journal = await journals.open({
identity: {
@@ -254,6 +297,56 @@ describe('AgentSessionSubscribers', () => {
expect(events.at(-1)).toMatchObject({ type: 'batch', fence: 2 })
})
+ it('publishes latest turn activity without advancing or adding journal rows', async () => {
+ const journal = await journals.open({
+ identity: {
+ sessionId: SESSION,
+ workspaceId: 'workspace-1',
+ hostId: 'local',
+ agent: 'codex',
+ providerHandle: { kind: 'codex', threadId: 'thread-1' }
+ },
+ journalDir: join(root, 'activity-journal')
+ })
+ const subscribers = new AgentSessionSubscribers()
+ const events: AgentSessionSubscribeEvent[] = []
+ subscribers.open({
+ id: 'subscriber-1',
+ sessionId: SESSION,
+ journal,
+ fence: 1,
+ emit: (event) => events.push(event)
+ })
+ const cursor = journal.cursor()
+
+ subscribers.publish(SESSION, journal, {
+ turnId: 'turn-1',
+ text: 'Inspecting the session wire'
+ })
+
+ expect(journal.cursor()).toEqual(cursor)
+ expect(events.at(-1)).toEqual({
+ type: 'batch',
+ sessionId: SESSION,
+ batch: { cursor, items: [], removedItemIds: [], submissions: [] },
+ fence: 1,
+ activity: { turnId: 'turn-1', text: 'Inspecting the session wire' }
+ })
+
+ subscribers.close(SESSION, 'subscriber-1')
+ subscribers.publish(SESSION, journal, null)
+ subscribers.open({
+ id: 'reconnected',
+ sessionId: SESSION,
+ journal,
+ fence: 1,
+ cursor,
+ emit: (event) => events.push(event)
+ })
+ expect(journal.cursor()).toEqual(cursor)
+ expect(events.at(-1)).toMatchObject({ activity: null })
+ })
+
it('catches a subscriber up past a pre-existing unsendable removal with a bounded reset', async () => {
const journalDir = join(root, 'oversized-removal-journal')
const seeded = await journals.open({
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts
index 29dffa6a687..ba439e6427b 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts
@@ -11,8 +11,10 @@ import type {
import {
AGENT_SESSION_HISTORY_MAX_LIMIT,
type AgentSessionBackgroundTaskState,
+ type AgentSessionSlashCommand,
type AgentSessionHandoffStatus,
- type AgentSessionSubscribeEvent
+ type AgentSessionSubscribeEvent,
+ type AgentSessionTurnActivity
} from '../../../shared/agent-session-wire'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import {
@@ -34,9 +36,11 @@ type Subscriber = {
emit: AgentSessionSubscriberEmit
cursor: AgentJournalCursor
fence: number
+ commands?: AgentSessionSlashCommand[] | null
}
export type AgentSessionSubscribersHooks = {
+ readCommands?: (sessionId: string) => AgentSessionSlashCommand[] | undefined
/** Fires after any publication that can change journal content, whether or not anyone
* is subscribed to the transcript: session lists project status from this same edge. */
onJournalPublished?: (sessionId: string, journal: AgentSessionJournal) => void
@@ -44,6 +48,7 @@ export type AgentSessionSubscribersHooks = {
export class AgentSessionSubscribers {
private readonly bySession = new Map>()
+ private readonly activityBySession = new Map()
constructor(private readonly hooks: AgentSessionSubscribersHooks = {}) {}
@@ -81,7 +86,8 @@ export class AgentSessionSubscribers {
page,
fence: input.fence,
...(input.handoff ? { handoff: input.handoff } : {}),
- ...(input.backgroundTasks !== undefined ? { backgroundTasks: input.backgroundTasks } : {})
+ ...(input.backgroundTasks !== undefined ? { backgroundTasks: input.backgroundTasks } : {}),
+ ...this.activityField(input.sessionId)
})
subscriber.cursor = page.liveCursor ?? page.window.nextCursor
}
@@ -103,11 +109,24 @@ export class AgentSessionSubscribers {
}
/** Fan out whatever each subscriber has not yet seen. */
- publish(sessionId: string, journal: AgentSessionJournal): void {
+ publish(
+ sessionId: string,
+ journal: AgentSessionJournal,
+ activity?: AgentSessionTurnActivity | null
+ ): void {
+ if (activity !== undefined) {
+ if (activity) {
+ this.activityBySession.set(sessionId, activity)
+ } else {
+ this.activityBySession.delete(sessionId)
+ }
+ }
for (const subscriber of this.subscribers(sessionId)) {
- this.deliver(subscriber, journal)
+ this.deliver(subscriber, journal, undefined, false, undefined, activity)
+ }
+ if (activity === undefined) {
+ this.hooks.onJournalPublished?.(sessionId, journal)
}
- this.hooks.onJournalPublished?.(sessionId, journal)
}
/** Force every subscriber back to a bounded tail page — recovery, epoch
@@ -127,7 +146,8 @@ export class AgentSessionSubscribers {
reset: reason,
page,
fence,
- ...(backgroundTasks !== undefined ? { backgroundTasks } : {})
+ ...(backgroundTasks !== undefined ? { backgroundTasks } : {}),
+ ...this.activityField(sessionId)
})
subscriber.cursor = page.liveCursor ?? page.window.nextCursor
subscriber.fence = fence
@@ -148,7 +168,8 @@ export class AgentSessionSubscribers {
sessionId,
page,
fence,
- ...(backgroundTasks !== undefined ? { backgroundTasks } : {})
+ ...(backgroundTasks !== undefined ? { backgroundTasks } : {}),
+ ...this.activityField(sessionId)
})
subscriber.cursor = page.liveCursor ?? page.window.nextCursor
subscriber.fence = fence
@@ -205,8 +226,15 @@ export class AgentSessionSubscribers {
journal: AgentSessionJournal,
handoff?: AgentSessionHandoffStatus,
emitCheckpoint = false,
- backgroundTasks?: AgentSessionBackgroundTaskState | null
+ backgroundTasks?: AgentSessionBackgroundTaskState | null,
+ activity?: AgentSessionTurnActivity | null
): void {
+ const publishedActivity =
+ activity !== undefined
+ ? activity
+ : emitCheckpoint
+ ? (this.activityBySession.get(subscriber.sessionId) ?? null)
+ : undefined
while (true) {
const result = readAgentSessionHistory(journal, {
sessionId: subscriber.sessionId,
@@ -223,7 +251,8 @@ export class AgentSessionSubscribers {
page,
fence: subscriber.fence,
...(handoff ? { handoff } : {}),
- ...(backgroundTasks !== undefined ? { backgroundTasks } : {})
+ ...(backgroundTasks !== undefined ? { backgroundTasks } : {}),
+ ...(publishedActivity !== undefined ? { activity: publishedActivity } : {})
})
subscriber.cursor = page.liveCursor ?? page.window.nextCursor
return
@@ -231,7 +260,10 @@ export class AgentSessionSubscribers {
const page = result.page
const advanced = page.window.nextCursor.sequence > subscriber.cursor.sequence
if (!advanced) {
- if (handoff || emitCheckpoint) {
+ const commandsChanged =
+ this.hooks.readCommands !== undefined &&
+ (this.hooks.readCommands(subscriber.sessionId) ?? null) !== subscriber.commands
+ if (handoff || emitCheckpoint || publishedActivity !== undefined || commandsChanged) {
this.emit(subscriber, {
type: 'batch',
sessionId: subscriber.sessionId,
@@ -243,7 +275,8 @@ export class AgentSessionSubscribers {
},
fence: subscriber.fence,
...(handoff ? { handoff } : {}),
- ...(backgroundTasks !== undefined ? { backgroundTasks } : {})
+ ...(backgroundTasks !== undefined ? { backgroundTasks } : {}),
+ ...(publishedActivity !== undefined ? { activity: publishedActivity } : {})
})
}
return
@@ -259,7 +292,8 @@ export class AgentSessionSubscribers {
},
fence: subscriber.fence,
...(handoff ? { handoff } : {}),
- ...(backgroundTasks !== undefined ? { backgroundTasks } : {})
+ ...(backgroundTasks !== undefined ? { backgroundTasks } : {}),
+ ...(publishedActivity !== undefined ? { activity: publishedActivity } : {})
})
subscriber.cursor = page.window.nextCursor
if (!page.hasNewer || !this.isActive(subscriber)) {
@@ -268,15 +302,20 @@ export class AgentSessionSubscribers {
}
}
- private isActive(subscriber: Subscriber): boolean {
- return this.bySession.get(subscriber.sessionId)?.get(subscriber.id) === subscriber
- }
+ private isActive = (subscriber: Subscriber): boolean =>
+ this.bySession.get(subscriber.sessionId)?.get(subscriber.id) === subscriber
/** A dead transport cannot be allowed to turn a durable mutation into an
* unknown outcome or poison every later publication. */
private emit(subscriber: Subscriber, event: AgentSessionSubscribeEvent): void {
try {
- subscriber.emit(event)
+ const commands = this.hooks.readCommands?.(subscriber.sessionId) ?? null
+ const includeCommands =
+ this.hooks.readCommands !== undefined &&
+ event.type !== 'end' &&
+ (event.type !== 'batch' || commands !== subscriber.commands)
+ subscriber.emit(includeCommands ? { ...event, commands: commands ?? null } : event)
+ subscriber.commands = commands
} catch {
this.drop(subscriber)
}
@@ -289,4 +328,8 @@ export class AgentSessionSubscribers {
this.bySession.delete(subscriber.sessionId)
}
}
+
+ private activityField(sessionId: string): { activity: AgentSessionTurnActivity | null } {
+ return { activity: this.activityBySession.get(sessionId) ?? null }
+ }
}
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts
index 68e5b290847..cb1685405a1 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts
@@ -23,5 +23,6 @@ export async function performSetOption(
throw error
}
await ctx.persistOptions(applied ?? { [input.key]: input.value })
+ ctx.publish()
return { ok: true, value: { ...input, ...(applied ? { options: { ...applied } } : {}) } }
}
diff --git a/src/main/native-chat/transcript-line-decoders-codex-skill-context.test.ts b/src/main/native-chat/transcript-line-decoders-codex-skill-context.test.ts
new file mode 100644
index 00000000000..3c7e2e50759
--- /dev/null
+++ b/src/main/native-chat/transcript-line-decoders-codex-skill-context.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from 'vitest'
+import { decodeCodexTranscriptLine } from './transcript-line-decoders-codex'
+
+describe('Codex transcript skill context', () => {
+ it.each(['message', 'response_item'])(
+ 'preserves prompt text and images beside a skill expansion in %s',
+ (type) => {
+ const message = {
+ type: 'message',
+ role: 'user',
+ content: [
+ { type: 'text', text: 'Inspect this image' },
+ { type: 'text', text: '\nInstructions\n' },
+ { type: 'image', url: 'https://example.test/image.png' }
+ ]
+ }
+ const record = type === 'message' ? message : { type, payload: message }
+ expect(decodeCodexTranscriptLine(JSON.stringify(record), 'mixed')?.blocks).toEqual([
+ { type: 'text', text: 'Inspect this image' },
+ { type: 'image-ref', url: 'https://example.test/image.png' }
+ ])
+ }
+ )
+
+ it('preserves an authoritative user event containing a literal skill wrapper', () => {
+ const text = 'Explain this XML'
+ expect(
+ decodeCodexTranscriptLine(
+ JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: text } }),
+ 'submitted'
+ )?.blocks
+ ).toEqual([{ type: 'text', text }])
+ })
+
+ it.each(['', ' \n'])(
+ 'drops expanded skill response items beginning with %j',
+ (prefix) => {
+ const message = {
+ type: 'message',
+ role: 'user',
+ content: [{ type: 'text', text: `${prefix}\nexample\nInstructions\n` }]
+ }
+ for (const record of [message, { type: 'response_item', payload: message }]) {
+ expect(decodeCodexTranscriptLine(JSON.stringify(record), 'context')).toBeNull()
+ }
+ }
+ )
+
+ it.each(['$example', 'Explain tags', 'user XML'])(
+ 'preserves the actual user prompt %j',
+ (text) => {
+ expect(
+ decodeCodexTranscriptLine(
+ JSON.stringify({
+ type: 'response_item',
+ payload: {
+ type: 'message',
+ role: 'user',
+ content: [{ type: 'text', text }]
+ }
+ }),
+ 'user'
+ )?.blocks
+ ).toEqual([{ type: 'text', text }])
+ }
+ )
+
+ it('preserves assistant explanations containing the skill wrapper', () => {
+ expect(
+ decodeCodexTranscriptLine(
+ JSON.stringify({
+ type: 'response_item',
+ payload: {
+ type: 'message',
+ role: 'assistant',
+ content: [{ type: 'text', text: 'example' }]
+ }
+ }),
+ 'assistant'
+ )?.role
+ ).toBe('assistant')
+ })
+})
diff --git a/src/main/native-chat/transcript-line-decoders-codex.ts b/src/main/native-chat/transcript-line-decoders-codex.ts
index d70bd7a7c80..229ace2a461 100644
--- a/src/main/native-chat/transcript-line-decoders-codex.ts
+++ b/src/main/native-chat/transcript-line-decoders-codex.ts
@@ -48,7 +48,9 @@ function codexUnwrappedResponseItem(
return codexResponseItem(record, id, timestamp)
}
const role = record.role === 'assistant' ? 'assistant' : record.role === 'user' ? 'user' : null
- const blocks = codexTurnItemBlocks(record.content)
+ const decodedBlocks = codexTurnItemBlocks(record.content)
+ const blocks =
+ role === 'user' ? decodedBlocks.filter((block) => !isSkillContext(block)) : decodedBlocks
return role && blocks.length > 0 ? { id, role, blocks, timestamp, source: 'transcript' } : null
}
@@ -63,7 +65,9 @@ function codexResponseItem(
if (!role) {
return null
}
- const blocks = claudeContentBlocks(payload.content)
+ const decodedBlocks = claudeContentBlocks(payload.content)
+ const blocks =
+ role === 'user' ? decodedBlocks.filter((block) => !isSkillContext(block)) : decodedBlocks
if (blocks.length === 0) {
return null
}
@@ -108,6 +112,11 @@ function codexResponseItem(
return null
}
+// Explicit skill expansions are model context, not the user's recorded prompt.
+function isSkillContext(block: NativeChatBlock): boolean {
+ return block.type === 'text' && block.text.trimStart().slice(0, 7).toLowerCase() === ''
+}
+
function codexEventMessage(
payload: Record,
id: string,
diff --git a/src/main/orca-profiles/profile-cloud-auth-config.ts b/src/main/orca-profiles/profile-cloud-auth-config.ts
index 09cfd8dfc6b..f6e56058935 100644
--- a/src/main/orca-profiles/profile-cloud-auth-config.ts
+++ b/src/main/orca-profiles/profile-cloud-auth-config.ts
@@ -19,6 +19,7 @@ const DEFAULT_SCOPE = 'openid profile email offline_access'
const PRODUCTION_API_BASE_URL = 'https://login.onorca.dev'
const PRODUCTION_CLIENT_ID = 'orca-desktop'
const PRODUCTION_RELAY_DIRECTOR_URL = 'https://relay.onorca.dev'
+const PRODUCTION_PUSH_GATEWAY_URL = 'https://push.onorca.dev'
// Why: packaged main bundles never define NODE_ENV, so packaged-ness is the
// only reliable production signal for gating dev-only auth escape hatches.
@@ -124,6 +125,18 @@ export function getOrcaCloudAuthConfig(
}
}
+/**
+ * Where the host registers phones for background push. Deliberately outside
+ * OrcaCloudAuthConfig: the push gateway authenticates with the host keypair, so an
+ * accountless host reaches it on exactly the same path as a signed-in one.
+ */
+export function getOrcaPushGatewayUrl(
+ env: NodeJS.ProcessEnv = process.env,
+ packaged: boolean = isPackagedOrcaBuild()
+): string {
+ return cleanOrigin(env.ORCA_PUSH_GATEWAY_URL, !packaged) ?? PRODUCTION_PUSH_GATEWAY_URL
+}
+
export function allowsPlaintextOrcaCloudSession(
env: NodeJS.ProcessEnv = process.env,
packaged: boolean = isPackagedOrcaBuild()
diff --git a/src/main/persistence-deregistered-repo-residue.test.ts b/src/main/persistence-deregistered-repo-residue.test.ts
index 3a7a3372b3c..a7fb4d7353f 100644
--- a/src/main/persistence-deregistered-repo-residue.test.ts
+++ b/src/main/persistence-deregistered-repo-residue.test.ts
@@ -1,7 +1,5 @@
// Why this file exists: deregistering a project used to strand every row it owned. No sweeper could
-// reach them -- the missing-directory prune is gated on the repo still being registered, and a
-// paired client's mirror of a remote host's rows is keyed by ids that client never registers, so the
-// owning host's removal never reached it (#17776).
+// reach them because the missing-directory prune is gated on the repo still being registered.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { rmSync, mkdtempSync } from 'node:fs'
import { join } from 'node:path'
@@ -103,7 +101,7 @@ describe('deregistered repo residue', () => {
expect(session.sleepingAgentSessionsByPaneKey ?? {}).toEqual({})
})
- it("sweeps a remote host's session partition the owning host's removal can never reach", async () => {
+ it('keeps a remote session whose repo is not registered on the desktop', async () => {
writeDataFile({
schemaVersion: 1,
repos: [makeRepo({ id: LIVE_REPO, path: '/workspace/live' })],
@@ -117,8 +115,10 @@ describe('deregistered repo residue', () => {
store.flush()
const partition = store.getWorkspaceSession(RUNTIME_HOST)
- expect(partition.tabsByWorktree).toEqual({})
- expect(partition.activeTabTypeByWorktree).toEqual({})
+ expect(partition.tabsByWorktree[GONE_WORKTREE]).toHaveLength(1)
+ expect(partition.activeTabTypeByWorktree).toEqual(
+ sessionFor(GONE_WORKTREE).activeTabTypeByWorktree
+ )
})
it('keeps rows for every registered repo, on any execution host', async () => {
@@ -204,15 +204,13 @@ describe('deregistered repo residue', () => {
schemaVersion: 1,
repos: [makeRepo({ id: LIVE_REPO, path: '/workspace/live' })],
worktreeMeta: {},
- workspaceSessionsByHostId: {
- [RUNTIME_HOST]: { ...getDefaultWorkspaceSession(), ...session }
- }
+ workspaceSession: { ...getDefaultWorkspaceSession(), ...session }
})
const store = await createStore()
store.flush()
- const partition = store.getWorkspaceSession(RUNTIME_HOST)
+ const partition = store.getWorkspaceSession()
expect(partition.activeWorktreeId ?? null).toBeNull()
expect(partition.activeWorkspaceKey ?? null).toBeNull()
expect(partition.activeWorktreeIdsOnShutdown ?? []).toEqual([])
diff --git a/src/main/persistence-remote-session-startup.test.ts b/src/main/persistence-remote-session-startup.test.ts
new file mode 100644
index 00000000000..ddb3f57827b
--- /dev/null
+++ b/src/main/persistence-remote-session-startup.test.ts
@@ -0,0 +1,109 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import { getDefaultWorkspaceSession } from '../shared/constants'
+import type { BrowserPage, BrowserWorkspace } from '../shared/browser-workspace-types'
+import { createStore, makeRepo, testState } from './persistence-test-harness'
+
+vi.mock('./ssh/ssh-config-parser', () => ({
+ loadUserSshConfig: vi.fn(),
+ sshConfigHostsToTargets: vi.fn()
+}))
+vi.mock('electron', () => ({
+ app: { getPath: () => testState.dir },
+ safeStorage: { isEncryptionAvailable: () => false }
+}))
+vi.mock('./telemetry/client', () => ({ track: vi.fn() }))
+vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({}) }))
+
+const HOST = 'runtime:paired-host'
+const REPO = 'remote-repo'
+const WORKTREE = `${REPO}::/remote/project`
+const PAGE: BrowserPage = {
+ id: 'page-1',
+ workspaceId: 'browser-1',
+ worktreeId: WORKTREE,
+ url: 'https://example.test/moved',
+ title: 'Moved page',
+ loading: false,
+ canGoBack: true,
+ canGoForward: false,
+ faviconUrl: null,
+ loadError: null,
+ createdAt: 1,
+ browserRuntimeEnvironmentId: 'paired-host',
+ remoteBrowserPageId: 'remote-page-1',
+ remoteBrowserPageClientHosted: true
+}
+const BROWSER: BrowserWorkspace = {
+ id: PAGE.workspaceId,
+ worktreeId: WORKTREE,
+ sessionProfileId: null,
+ activePageId: PAGE.id,
+ pageIds: [PAGE.id],
+ url: PAGE.url,
+ title: PAGE.title,
+ loading: false,
+ faviconUrl: null,
+ canGoBack: true,
+ canGoForward: false,
+ loadError: null,
+ createdAt: 1
+}
+
+function browserSession() {
+ return {
+ ...getDefaultWorkspaceSession(),
+ browserTabsByWorktree: { [WORKTREE]: [BROWSER] },
+ browserPagesByWorkspace: { [BROWSER.id]: [PAGE] },
+ activeBrowserTabIdByWorktree: { [WORKTREE]: BROWSER.id }
+ }
+}
+
+describe('remote session startup ownership', () => {
+ beforeEach(() => {
+ testState.dir = mkdtempSync(join(tmpdir(), 'orca-remote-session-'))
+ })
+ afterEach(() => {
+ rmSync(testState.dir, { recursive: true, force: true })
+ })
+
+ it('keeps a paired browser row and its hosting identity across two Store reloads', () => {
+ const seed = createStore()
+ seed.addRepo(makeRepo({ id: 'local-repo', path: join(testState.dir, 'local') }))
+ seed.setWorkspaceSession(browserSession(), HOST)
+ seed.flush()
+
+ for (let i = 0; i < 2; i += 1) {
+ const reloaded = createStore()
+ expect(reloaded.getWorkspaceSession(HOST).browserPagesByWorkspace).toEqual({
+ [BROWSER.id]: [PAGE]
+ })
+ expect(reloaded.sweepDeregisteredRepoResidue()).toEqual([])
+ reloaded.flush()
+ }
+ })
+
+ it('retains remote metadata when no session or local catalog row names its repo', () => {
+ const seed = createStore()
+ seed.setWorktreeMetaForHost(WORKTREE, HOST, { displayName: 'Remote work' })
+ seed.flush()
+ const reloaded = createStore()
+ expect(reloaded.getWorktreeMeta(WORKTREE)).toMatchObject({ displayName: 'Remote work' })
+ expect(reloaded.sweepDeregisteredRepoResidue()).toEqual([])
+ reloaded.flush()
+ })
+
+ it('still applies an explicit remote project removal', () => {
+ const seed = createStore()
+ seed.setWorkspaceSession(browserSession(), HOST)
+ seed.flush()
+ const reloaded = createStore()
+ // Assert the row survived load first, or an empty partition below would prove nothing.
+ expect(reloaded.getWorkspaceSession(HOST).browserPagesByWorkspace).not.toEqual({})
+ reloaded.removeProjectForHost(REPO, HOST)
+ reloaded.flush()
+ expect(createStore().getWorkspaceSession(HOST).browserPagesByWorkspace).toEqual({})
+ })
+})
diff --git a/src/main/persistence/loading-store/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts
index 535108845e1..e5c75c1ff01 100644
--- a/src/main/persistence/loading-store/repo-lifecycle-operations.ts
+++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts
@@ -136,11 +136,9 @@ export class RepoLifecycleOperations {
/**
* Drop every persisted row owned by a repo id that is no longer registered.
*
- * Runs at load because no removal path can: `removeProject` only fires while the repo is still in
- * `state.repos`, and a paired client's mirror of a remote host's rows is keyed by ids that client
- * never registers, so the owning host's removal never reaches it (#17776). An orphan has no owner
- * that could object, so this ignores the session-ownership and local-execution-host gates the
- * missing-directory sweeper needs.
+ * Runs at load to reach leftover local rows after deregistration. Rows owned by a `runtime:*`
+ * host are exempt: this runs before pairing, so their absence from the local catalog cannot
+ * establish deletion. Only an explicit `removeProjectForHost` retires them.
*/
sweepDeregisteredRepoResidue(): string[] {
const state = this[repoLifecycleOperationsContext].runtime.state
diff --git a/src/main/persistence/restoring-sessions/session-worktree-ownership.ts b/src/main/persistence/restoring-sessions/session-worktree-ownership.ts
index e06e39dc5c5..5c54af92975 100644
--- a/src/main/persistence/restoring-sessions/session-worktree-ownership.ts
+++ b/src/main/persistence/restoring-sessions/session-worktree-ownership.ts
@@ -209,7 +209,7 @@ export function collectWorkspaceSessionWorktreeOwners(
return owners
}
-function addWorkspaceSessionWorktreeOwners(
+export function addWorkspaceSessionWorktreeOwners(
session: WorkspaceSessionState,
collector: WorktreeOwnerCandidateCollector
): void {
diff --git a/src/main/persistence/tracking-repos/deregistered-repo-residue.ts b/src/main/persistence/tracking-repos/deregistered-repo-residue.ts
index c52bddbb712..cf97adc11ae 100644
--- a/src/main/persistence/tracking-repos/deregistered-repo-residue.ts
+++ b/src/main/persistence/tracking-repos/deregistered-repo-residue.ts
@@ -1,19 +1,61 @@
import type { PersistedState } from '../../../shared/persisted-state-types'
-import { getWorktreeIdFromHostIdentity } from '../../../shared/worktree/host-qualified-identity'
+import {
+ getExecutionHostIdFromWorktreeHostIdentity,
+ getWorktreeIdFromHostIdentity
+} from '../../../shared/worktree/host-qualified-identity'
+import { parseExecutionHostId } from '../../../shared/execution-host'
+import { addWorkspaceSessionWorktreeOwners } from '../restoring-sessions/session-worktree-ownership'
import { splitWorktreeId } from '../../../shared/worktree/id'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { SESSION_FIELDS_PRUNED_BY_OWNER_KEY } from '../../orca-profiles/profile-project-session-field-disposition'
import { ownerKeyWorktreeIds } from '../../orca-profiles/profile-project-worktree-identity'
+/** A `runtime:*` host addresses a paired Orca desktop's rows, whose catalog lives on that host. */
+const isPairedHost = (hostId: string | null | undefined): boolean =>
+ parseExecutionHostId(hostId)?.kind === 'runtime'
+
+/** Repo ids an owner key can name, across both readings (see `ownerKeyWorktreeIds`). */
+function ownerKeyRepoIds(ownerKey: string | null | undefined): string[] {
+ return ownerKey
+ ? ownerKeyWorktreeIds(ownerKey).flatMap((worktreeId) => {
+ const repoId = splitWorktreeId(worktreeId)?.repoId
+ return repoId ? [repoId] : []
+ })
+ : []
+}
+
/**
* Repo ids that still own persisted rows but no longer appear in `state.repos`.
*
- * Why nothing else finds them: every other sweeper is gated on the repo still being registered, so
- * deregistering a project stranded the rows it owned permanently — including a paired client's
- * mirror of a remote host's session partition, which no local repo removal can reach (#17776).
+ * Rows owned by a `runtime:*` host are held live instead of swept: a paired client mirrors that
+ * host's sessions without ever registering its repos, and this runs in the Store constructor,
+ * before pairing, so catalog absence there proves nothing (#17776 read it as proof and deleted
+ * live sessions). The cost is that residue outliving a removal is no longer swept for those hosts.
*/
export function collectDeregisteredRepoIds(state: PersistedState): Set {
const liveRepoIds = new Set(state.repos.map((repo) => repo.id))
+ const retainOwner = (ownerKey: string | null | undefined): void => {
+ for (const repoId of ownerKeyRepoIds(ownerKey)) {
+ liveRepoIds.add(repoId)
+ }
+ }
+ // `owners` goes unread: the walker only ever calls `addOwner`.
+ const retainCollector = { owners: new Set