Merge origin/main; reconcile with #7630's pendingData cap

#7630 landed the same 2MB cap + droppedBacklog restore flag this branch
introduced independently — adopt its mechanism wholesale (constants, flag
naming, renderer threading, cap tests). This branch keeps what #7630 does not
cover: releasing the queue + in-flight ACK budget on teardown paths that skip
sendPtyExitToRenderer (SSH connection drop, synthetic-kill dedup), and dropping
renderer delivery for output arriving inside the synthetic-kill window.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-07-09 14:12:02 -07:00
co-authored by Orca
998 changed files with 79606 additions and 7337 deletions
+13
View File
@@ -10,7 +10,14 @@ on:
- 'config/scripts/computer-use-skill-guidance.test.mjs'
- 'config/scripts/computer-use-smoke.mjs'
- 'config/scripts/computer-use-smoke.test.mjs'
- 'config/scripts/daemon-boot-smoke.mjs'
- 'config/scripts/verify-computer-native.mjs'
# Why: the native-smoke job boots the built terminal daemon under plain
# Node, so any change to the daemon bundle graph or the main build must
# re-run it (the v1.4.129-rc.1 daemon outage shipped with green CI).
- 'electron.vite.config.ts'
- 'build-plugins/**'
- 'src/main/daemon/**'
- 'native/computer-use-macos/**'
- 'native/computer-use-linux/**'
- 'native/computer-use-windows/**'
@@ -98,6 +105,12 @@ jobs:
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm build:electron-vite
# Why: boot the BUILT daemon-entry under plain Node the way production
# forks it. v1.4.129-rc.1 shipped a daemon that exited at module load
# (leaked electron require) while every other check passed; this fails
# the PR when the built daemon cannot start on ubuntu-22.04 / windows.
- name: Daemon boot smoke
run: node config/scripts/daemon-boot-smoke.mjs
- if: runner.os == 'Linux'
env:
ORCA_COMPUTER_E2E: '1'
@@ -0,0 +1,97 @@
name: Daemon Relocation Spike
# Why: Phase 1 of the Windows update-survival work relocates the terminal
# daemon's file closure out of the install dir so it survives an update. Before
# implementing it in the app, this spike finds the MINIMAL set of packaged files
# that a copied Orca.exe (run as node) needs to start the daemon and drive a
# ConPTY session from a relocated directory, holding no install-dir file locks.
# Runs from the feature branch via push (workflow_dispatch only works on the
# default branch); main is never touched.
on:
push:
branches:
- Jinwoo-H/windows-update-survival
paths:
- 'tools/daemon-relocation-spike/**'
- '.github/workflows/daemon-relocation-spike.yml'
workflow_dispatch: {}
permissions:
contents: read
concurrency:
group: daemon-relocation-spike-${{ github.ref }}
cancel-in-progress: true
jobs:
spike:
name: relocation file-set spike
runs-on: windows-2022
timeout-minutes: 40
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# This job only reads the repo to build/run the spike; it never pushes.
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Why: cache the unpacked build keyed on the inputs that affect it, so
# spike-script-only edits skip the ~15 min electron-builder build.
- name: Cache unpacked build
id: cache-unpacked
uses: actions/cache@v4
with:
path: dist/win-unpacked
key: win-unpacked-${{ hashFiles('src/**', 'config/**', 'package.json', 'pnpm-lock.yaml') }}
- name: Build unpacked app
if: steps.cache-unpacked.outputs.cache-hit != 'true'
run: pnpm run build:unpack
- name: Run relocation spike (all tiers)
shell: pwsh
run: |
New-Item -ItemType Directory -Force artifacts | Out-Null
$log = "artifacts/spike-output.log"
$tiers = @('full', 'no-gpu', 'minimal')
$any = $false
foreach ($tier in $tiers) {
"===== TIER: $tier =====" | Tee-Object -FilePath $log -Append
$work = Join-Path $env:RUNNER_TEMP "spike-$tier"
# --keep-work-dir so the per-tier daemon stdout/stderr logs survive
# for the artifact upload (the spike otherwise removes work-dir),
# which is what diagnoses a tier that fails to reach ready.
node tools/daemon-relocation-spike/spike.mjs `
--app-dir dist/win-unpacked `
--work-dir "$work" `
--tier $tier `
--keep-work-dir 2>&1 | Tee-Object -FilePath $log -Append
if ($LASTEXITCODE -eq 0) { $any = $true }
}
if (-not $any) { throw "No tier passed the relocation spike" }
- name: Upload spike output
if: always()
uses: actions/upload-artifact@v7
with:
name: daemon-relocation-spike-output
path: |
artifacts/spike-output.log
${{ runner.temp }}/spike-*/**/*.log
retention-days: 7
if-no-files-found: warn
+8
View File
@@ -69,6 +69,14 @@ jobs:
- name: Check reliability gate manifest
run: pnpm run check:reliability-gates
# Why: oxlint fails any file over max-lines that is NOT suppressed, so this
# ratchet forbids ADDING a new suppression (inline disable or mobile max
# bump). Existing oversized files are grandfathered in
# config/max-lines-baseline.txt, which may only shrink — new bypasses fail
# here with a clear message instead of silently growing the debt.
- name: Enforce max-lines ratchet (no new bypasses)
run: pnpm run check:max-lines-ratchet
# Why: project-owned type declarations must live in .ts so tsc
# actually checks them. TypeScript's skipLibCheck: true (inherited
# from @electron-toolkit/tsconfig) silently widens unresolved names
+330 -1
View File
@@ -719,6 +719,10 @@ jobs:
~/.cache/electron-builder
runs-on: ${{ matrix.os }}
# Why: hosted runners hard-cap jobs at 6h; the Windows SignPath waits
# (1h inner + 4h installer) are budgeted to fit under this with the
# build itself, so a slow approval can't kill the job mid-flow.
timeout-minutes: 360
permissions:
actions: read
@@ -958,6 +962,244 @@ jobs:
}
}
# ── Windows inner-binary signing (issue #7785) ─────────────────────
# Why: SignPath cannot deep-sign inside NSIS installers, so inner PE
# files (Orca.exe, node-pty *.node, DLLs) are signed via a separate zip
# request, then the installer is rebuilt from the signed tree before the
# existing installer signing request below. Every step in this chain is
# fail-open (continue-on-error + outcome gating): any failure ships the
# original installer with unsigned inner binaries, exactly like releases
# did before this chain existed. Rehearsed end to end in run 28988432001
# (.github/workflows/windows-signing-rehearsal.yml).
# Why: only unsigned PE files go to SignPath. Files that already carry a
# valid signature (Microsoft's OpenConsole.exe) must keep their signer.
- name: Stage unsigned inner PE files for signing
id: stage-inner
if: matrix.platform == 'win'
continue-on-error: true
shell: pwsh
run: |
$root = Resolve-Path 'dist/win-unpacked'
$stage = New-Item -ItemType Directory -Force -Path 'signing-stage'
$list = New-Object System.Collections.Generic.List[string]
$skipped = New-Object System.Collections.Generic.List[string]
Get-ChildItem -Path $root -Recurse -File |
Where-Object { $_.Extension -in '.exe', '.dll', '.node' } |
ForEach-Object {
$relative = [System.IO.Path]::GetRelativePath($root, $_.FullName)
$signature = Get-AuthenticodeSignature -FilePath $_.FullName
if ($signature.Status -eq 'Valid') {
$skipped.Add("$relative <already signed: $($signature.SignerCertificate.Subject)>")
return
}
$destination = Join-Path $stage.FullName $relative
New-Item -ItemType Directory -Force -Path (Split-Path $destination) | Out-Null
Copy-Item -Path $_.FullName -Destination $destination -Force
$list.Add($relative)
}
if (-not ($list -contains 'Orca.exe')) {
throw 'Orca.exe was not staged for signing; unpacked layout changed?'
}
if (-not ($list | Where-Object { $_ -like '*conpty_console_list.node' })) {
throw 'node-pty conpty_console_list.node was not staged; this is the file from issue #7785.'
}
Set-Content -Path 'inner-signing-list.txt' -Value ($list -join "`n")
Write-Host "Staged $($list.Count) unsigned PE files for signing:"
$list | ForEach-Object { Write-Host " $_" }
Write-Host "Skipped $($skipped.Count) already-signed files:"
$skipped | ForEach-Object { Write-Host " $_" }
- name: Upload unsigned inner binaries for SignPath
id: upload-unsigned-inner
if: matrix.platform == 'win' && steps.stage-inner.outcome == 'success'
continue-on-error: true
uses: actions/upload-artifact@v7
with:
name: orca-windows-inner-unsigned-${{ needs.cut.outputs.tag }}
path: signing-stage/**
if-no-files-found: error
- name: Submit inner binaries signing request
id: submit-inner-signing
if: matrix.platform == 'win' && steps.upload-unsigned-inner.outcome == 'success'
continue-on-error: true
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0
project-slug: orca
signing-policy-slug: release-signing
artifact-configuration-slug: windows-inner-binaries-zip
github-artifact-id: ${{ steps.upload-unsigned-inner.outputs.artifact-id }}
wait-for-completion: false
- name: Notify Slack that inner-binary signing is waiting for approval
id: notify-inner-signing
if: matrix.platform == 'win' && steps.submit-inner-signing.outcome == 'success'
continue-on-error: true
shell: pwsh
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
SIGNPATH_ORGANIZATION_ID: c37aa192-a27a-4377-9c90-5d6c95912dc0
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
SIGNPATH_REQUEST_URL: ${{ steps.submit-inner-signing.outputs.signing-request-web-url }}
TAG: ${{ needs.cut.outputs.tag }}
GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) {
throw 'SLACK_WEBHOOK_URL secret is required so release approvers know when SignPath is waiting.'
}
$requestUrl = $env:SIGNPATH_REQUEST_URL
if ([string]::IsNullOrWhiteSpace($requestUrl)) {
$requestUrl = "https://app.signpath.io/Web/$env:SIGNPATH_ORGANIZATION_ID/SigningRequests/$env:SIGNPATH_REQUEST_ID"
}
$message = "Orca Windows release $env:TAG inner-binaries signing request (1 of 2) is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>"
$payload = @{
text = $message
blocks = @(
@{
type = 'section'
text = @{
type = 'mrkdwn'
text = $message
}
}
)
} | ConvertTo-Json -Depth 5
Invoke-RestMethod -Method Post -Uri $env:SLACK_WEBHOOK_URL -ContentType 'application/json' -Body $payload
# Why gate on the notify outcome too: if nobody was told to approve,
# don't hold the release for the approval window — fall through and
# ship like today instead. The 1h wait (vs the installer's 4h) keeps
# both waits plus the build inside the 360-minute job cap; missing it
# falls through to today's unsigned-inner flow rather than blocking.
- name: Download signed inner binaries from SignPath
id: download-signed-inner
if: matrix.platform == 'win' && steps.submit-inner-signing.outcome == 'success' && steps.notify-inner-signing.outcome == 'success'
continue-on-error: true
shell: pwsh
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
run: |
Get-SignedArtifact `
-OrganizationId c37aa192-a27a-4377-9c90-5d6c95912dc0 `
-ApiToken $env:SIGNPATH_API_TOKEN `
-SigningRequestId $env:SIGNPATH_REQUEST_ID `
-OutputArtifactPath signed-inner.zip `
-Force `
-WaitForCompletionTimeoutInSeconds 3600
New-Item -ItemType Directory -Path signed-inner -Force
Expand-Archive -Path signed-inner.zip -DestinationPath signed-inner -Force
# Why: copy back strictly by the staged list so a layout mismatch in the
# returned artifact fails loudly (into fail-open) instead of silently
# shipping a mix of signed and unsigned binaries.
- name: Restore signed inner binaries into unpacked app
id: restore-signed-inner
if: matrix.platform == 'win' && steps.download-signed-inner.outcome == 'success'
continue-on-error: true
shell: pwsh
run: |
$root = Resolve-Path 'dist/win-unpacked'
$failures = New-Object System.Collections.Generic.List[string]
foreach ($relative in Get-Content 'inner-signing-list.txt') {
$signed = Get-ChildItem -Path signed-inner -Recurse -File |
Where-Object { [System.IO.Path]::GetRelativePath((Resolve-Path 'signed-inner'), $_.FullName).TrimStart('\', '/') -like "*$relative" } |
Select-Object -First 1
if ($null -eq $signed) {
$failures.Add("missing from signed artifact: $relative")
continue
}
$signature = Get-AuthenticodeSignature -FilePath $signed.FullName
if ($null -eq $signature.SignerCertificate) {
$failures.Add("returned without a signature: $relative")
continue
}
Copy-Item -Path $signed.FullName -Destination (Join-Path $root $relative) -Force
Write-Host ("{0,-14} {1} <{2}>" -f $signature.Status, $relative, $signature.SignerCertificate.Subject)
}
if ($failures.Count -gt 0) {
$failures | ForEach-Object { Write-Host "::error::$_" }
throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)."
}
# Why this step exists: electron-builder's CopyElevateHelper re-copies a
# pristine elevate.exe from its download cache over resources\elevate.exe
# on EVERY nsis pack — including the --prepackaged rebuild below — which
# clobbered the SignPath signature in v1.4.129-rc.4. There is no supported
# way to disable just the copy, so we overwrite the cache's copy with our
# signed one (identical bytes plus signature) so the clobber becomes a
# no-op. Known quirk: the cache persists across releases via actions/cache,
# so later runs may see elevate.exe as already signed and skip staging it —
# that is fine (the signature is timestamped) and the evidence gate checks
# elevate.exe in the shipped installer unconditionally. If this ever causes
# trouble, delete this step; the only effect is elevate.exe shipping
# unsigned again, which the evidence gate will flag.
- name: Replace cached elevate.exe with the signed copy
id: sign-elevate-cache
if: matrix.platform == 'win' && steps.restore-signed-inner.outcome == 'success'
continue-on-error: true
shell: pwsh
run: |
$signed = 'dist/win-unpacked/resources/elevate.exe'
if (-not (Test-Path $signed)) {
Write-Host '::warning::No elevate.exe in win-unpacked resources; nothing to protect from the rebuild clobber.'
exit 0
}
$signature = Get-AuthenticodeSignature -FilePath $signed
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
if ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') {
Write-Host "::warning::win-unpacked elevate.exe is not SignPath-signed ($($signature.Status), $subject); skipping cache swap."
exit 0
}
$cached = @(Get-ChildItem "$env:LOCALAPPDATA\electron-builder\Cache\nsis" -Recurse -Filter elevate.exe -ErrorAction SilentlyContinue)
if ($cached.Count -eq 0) {
Write-Host '::warning::No cached elevate.exe found (electron-builder cache layout changed?); the rebuild will pack the unsigned copy and the evidence gate will flag it.'
exit 0
}
foreach ($file in $cached) {
Copy-Item -Path $signed -Destination $file.FullName -Force
Write-Host "Replaced $($file.FullName) with the SignPath-signed copy."
}
- name: Rebuild NSIS installer from signed unpacked app
id: rebuild-nsis-signed
if: matrix.platform == 'win' && steps.restore-signed-inner.outcome == 'success'
continue-on-error: true
shell: pwsh
run: |
# Why: keep the pre-rebuild artifacts so a failed rebuild can fall
# back to shipping them unchanged (fail-open).
New-Item -ItemType Directory -Path prepack-backup -Force | Out-Null
Copy-Item 'dist/orca-windows-setup.exe' 'prepack-backup/orca-windows-setup.exe' -Force
Copy-Item 'dist/latest.yml' 'prepack-backup/latest.yml' -Force
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never --prepackaged "$env:GITHUB_WORKSPACE\dist\win-unpacked"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (-not (Test-Path 'dist/orca-windows-setup.exe')) {
throw 'electron-builder --prepackaged did not produce dist/orca-windows-setup.exe'
}
- name: Roll back to original installer after failed rebuild
if: matrix.platform == 'win' && steps.rebuild-nsis-signed.outcome == 'failure'
shell: pwsh
run: |
if (Test-Path 'prepack-backup/orca-windows-setup.exe') {
Copy-Item 'prepack-backup/orca-windows-setup.exe' 'dist/orca-windows-setup.exe' -Force
Copy-Item 'prepack-backup/latest.yml' 'dist/latest.yml' -Force
Write-Warning 'Restored pre-rebuild installer; this release ships with unsigned inner binaries.'
}
# ── End Windows inner-binary signing ───────────────────────────────
- name: Upload unsigned Windows installer for SignPath
if: matrix.platform == 'win'
id: upload-unsigned-windows-installer
@@ -992,6 +1234,7 @@ jobs:
SIGNPATH_REQUEST_URL: ${{ steps.submit-signing-request.outputs.signing-request-web-url }}
TAG: ${{ needs.cut.outputs.tag }}
GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
INNER_SIGNING_SUBMITTED: ${{ steps.submit-inner-signing.outcome == 'success' }}
run: |
if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) {
throw 'SLACK_WEBHOOK_URL secret is required so release approvers know when SignPath is waiting.'
@@ -1002,7 +1245,9 @@ jobs:
$requestUrl = "https://app.signpath.io/Web/$env:SIGNPATH_ORGANIZATION_ID/SigningRequests/$env:SIGNPATH_REQUEST_ID"
}
$message = "Orca Windows release $env:TAG is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>"
# Why: releases where inner signing fell through have only this one request.
$stage = if ($env:INNER_SIGNING_SUBMITTED -eq 'true') { 'installer signing request (2 of 2)' } else { 'signing request' }
$message = "Orca Windows release $env:TAG $stage is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>"
$payload = @{
text = $message
blocks = @(
@@ -1085,6 +1330,90 @@ jobs:
}
$signature.SignerCertificate | Format-List Subject,Issuer,NotBefore,NotAfter,Thumbprint
# Why: evidence gate for inner-binary signing (issue #7785, supersedes
# PR #7170's Orca.exe-only gate — this covers every staged .exe/.dll/.node
# by extracting the shipped installer). Warn-only until the flow has been
# proven on a real release, then flip ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED
# to 'true' so unsigned inner binaries block the release.
- name: Verify Windows inner binary signatures
if: matrix.platform == 'win'
shell: pwsh
env:
ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED: 'false'
INNER_SIGNING_COMPLETED: ${{ steps.rebuild-nsis-signed.outcome == 'success' }}
run: |
$required = $env:ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED -eq 'true'
if ($env:INNER_SIGNING_COMPLETED -ne 'true') {
$message = 'Windows inner-binary signing did not complete; this release ships unsigned inner binaries (fail-open, issue #7785).'
if ($required) { throw $message }
Write-Host "::warning::$message"
exit 0
}
# Why try/catch: while the gate is warn-only, even an unexpected
# script error (extraction hiccup, missing file) must not block
# the release — only the flip to required makes failures fatal.
try {
$report = New-Object System.Collections.Generic.List[string]
$failures = New-Object System.Collections.Generic.List[string]
# Why: verify the files a user actually gets on disk, not the build
# tree — 7z parses the NSIS exe directly as its embedded payload.
$7za = 'node_modules/7zip-bin/win/x64/7za.exe'
New-Item -ItemType Directory -Path inner-evidence-extract -Force | Out-Null
& $7za x 'dist/orca-windows-setup.exe' '-oinner-evidence-extract' -y | Out-Null
$root = Resolve-Path 'inner-evidence-extract'
# Why elevate.exe is always appended: staging skips already-signed
# files, and the persisted electron-builder cache can carry a
# previously signed elevate.exe — so it may be absent from the list
# in some runs, yet it is the file most at risk of losing its
# signature in the NSIS rebuild. Verify it in every release.
$targets = @(Get-Content 'inner-signing-list.txt')
if ($targets -notcontains 'resources\elevate.exe') {
$targets += 'resources\elevate.exe'
}
foreach ($relative in $targets) {
$path = Join-Path $root $relative
if (-not (Test-Path $path)) {
$failures.Add("missing from installer payload: $relative")
continue
}
$signature = Get-AuthenticodeSignature -FilePath $path
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
$line = "{0,-14} {1} <{2}>" -f $signature.Status, $relative, $subject
$report.Add($line)
Write-Host $line
if ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') {
$failures.Add("not signed by SignPath Foundation: $relative ($($signature.Status), $subject)")
}
}
Set-Content -Path 'inner-signing-evidence.txt' -Value ($report -join "`n")
if ($failures.Count -gt 0) {
$failures | ForEach-Object { Write-Host "::warning::$_" }
$message = "Windows inner-binary evidence gate found $($failures.Count) problems."
if ($required) { throw $message }
Write-Host "::warning::$message Fail-open until ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED is 'true'."
} else {
Write-Host "All $($targets.Count) inner binaries in the shipped installer are signed by SignPath Foundation."
}
} catch {
if ($required) { throw }
Write-Host "::warning::Windows inner-binary evidence gate errored: $_ (fail-open, issue #7785)."
}
- name: Upload Windows inner signing evidence
if: always() && matrix.platform == 'win'
uses: actions/upload-artifact@v7
with:
name: orca-windows-inner-signing-evidence-${{ needs.cut.outputs.tag }}
path: |
inner-signing-evidence.txt
inner-signing-list.txt
if-no-files-found: ignore
retention-days: 30
- name: Publish signed Windows release artifacts
if: matrix.platform == 'win'
uses: nick-fields/retry@v4
+145
View File
@@ -0,0 +1,145 @@
name: Windows Update-Survival E2E
# Why: proves what happens to terminal sessions across a real Windows app update
# by installing one released version, updating to another, and asserting on the
# packaged artifacts (daemon survival, terminal interactivity, zero console
# flashes). This MUST run on a disposable CI Windows: electron-builder's oneClick
# installer uninstalls the registry-registered copy of the app before every
# install, so running the harness on a machine with a real Orca install would
# delete it. A fresh runner has no Orca registered, so it is the only safe home
# for install-based testing. Manual dispatch only.
# Why: workflow_dispatch only works once a workflow is on the default branch.
# To exercise this from the unmerged feature branch WITHOUT touching main, a
# push trigger scoped to that exact branch runs the workflow from the branch's
# own tree. The paths filter keeps it to harness/workflow edits so ordinary
# commits don't spend 30 min of Windows runner time. Push runs have no inputs,
# so every parameter falls back to a default below.
on:
push:
branches:
- Jinwoo-H/windows-update-survival
paths:
- 'tools/win-update-e2e/**'
- '.github/workflows/win-update-e2e.yml'
workflow_dispatch:
inputs:
from_tag:
description: Release tag to install first (e.g. v1.4.124-rc.8)
required: true
type: string
to_tag:
description: Release tag to update to (e.g. v1.4.124-rc.9)
required: true
type: string
expect:
description: Expected outcome profile
required: true
type: choice
default: cold-restore
options:
- cold-restore
- survival
asset_pattern:
description: Installer asset glob
required: false
type: string
default: '*windows-setup.exe'
soak_seconds:
description: Post-relaunch console-window watch duration
required: false
type: string
default: '60'
permissions:
contents: read
# Why: cancel a superseded run when iterating — a new push to the branch makes
# the in-flight Windows job obsolete, so free the runner immediately.
concurrency:
group: win-update-e2e-${{ github.ref }}
cancel-in-progress: true
jobs:
update-e2e:
name: update ${{ inputs.from_tag || 'v1.4.124-rc.8' }} -> ${{ inputs.to_tag || 'v1.4.124-rc.9' }} (${{ inputs.expect || 'cold-restore' }})
runs-on: windows-2022
timeout-minutes: 30
env:
FROM_TAG: ${{ inputs.from_tag || 'v1.4.124-rc.8' }}
TO_TAG: ${{ inputs.to_tag || 'v1.4.124-rc.9' }}
EXPECT: ${{ inputs.expect || 'cold-restore' }}
ASSET_PATTERN: ${{ inputs.asset_pattern || '*windows-setup.exe' }}
SOAK_SECONDS: ${{ inputs.soak_seconds || '60' }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# This job only reads the repo and downloads a release; it never pushes.
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
# Why: the harness only needs its runtime deps (the Playwright Electron
# driver); it drives already-built installer artifacts, so no app build.
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Why: fail fast with a readable message if a tag has no matching Windows
# installer, rather than deep inside the harness.
- name: Download installers
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: pwsh
run: |
New-Item -ItemType Directory -Force artifacts/from | Out-Null
New-Item -ItemType Directory -Force artifacts/to | Out-Null
gh release download "$env:FROM_TAG" --repo "${{ github.repository }}" --pattern "$env:ASSET_PATTERN" --dir artifacts/from
gh release download "$env:TO_TAG" --repo "${{ github.repository }}" --pattern "$env:ASSET_PATTERN" --dir artifacts/to
$from = Get-ChildItem artifacts/from -Filter *.exe | Select-Object -First 1
$to = Get-ChildItem artifacts/to -Filter *.exe | Select-Object -First 1
if (-not $from) { throw "No installer matching '$env:ASSET_PATTERN' in $env:FROM_TAG" }
if (-not $to) { throw "No installer matching '$env:ASSET_PATTERN' in $env:TO_TAG" }
"FROM_EXE=$($from.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"TO_EXE=$($to.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
# Why: no --install-dir here. A CI runner has no real Orca registered, so
# the harness installs to the default per-user location and owns it — the
# isolated-install machinery exists only to (imperfectly) protect a real
# developer install, which does not apply on a throwaway runner.
- name: Run update-survival harness
id: harness
shell: pwsh
env:
# Why: on a driving failure the harness dumps a screenshot + DOM state
# here so the actual post-onboarding UI is visible in the artifacts.
ORCA_E2E_DIAG_DIR: artifacts/diag
run: |
$log = "artifacts/harness-output.log"
node tools/win-update-e2e/run.mjs `
--from "$env:FROM_EXE" `
--to "$env:TO_EXE" `
--expect "$env:EXPECT" `
--soak-seconds "$env:SOAK_SECONDS" 2>&1 | Tee-Object -FilePath $log
exit $LASTEXITCODE
- name: Upload harness output
if: always()
uses: actions/upload-artifact@v7
with:
name: win-update-e2e-output
# Why: log + diagnostics only, never the multi-hundred-MB installers.
path: |
artifacts/harness-output.log
artifacts/diag/**
retention-days: 7
if-no-files-found: warn
@@ -0,0 +1,116 @@
name: Windows Update-Survival E2E (branch build)
# Why: proves the Phase 1 daemon-host relocation actually makes terminal
# sessions SURVIVE a Windows update. Builds an (unsigned) installer FROM THIS
# BRANCH, then runs the win-update-e2e harness with --expect survival: install,
# open a terminal, silently update over it, relaunch, and assert the daemon PID
# is unchanged and the pre-update terminal is still interactive with no console
# flashing. Runs from the feature branch via push (workflow_dispatch only works
# on the default branch); main is never touched. A CI runner is the only safe
# place to install/update — see the relocation post-mortem.
on:
push:
branches:
- Jinwoo-H/windows-update-survival
paths:
- 'src/main/daemon/**'
- 'src/main/pty/**'
- 'tools/win-update-e2e/**'
- 'config/electron-builder.config.cjs'
- '.github/workflows/win-update-survival-e2e.yml'
workflow_dispatch:
inputs:
expect:
description: Expected outcome profile
required: true
type: choice
default: survival
options:
- survival
- cold-restore
permissions:
contents: read
concurrency:
group: win-update-survival-e2e-${{ github.ref }}
cancel-in-progress: true
jobs:
survival:
name: survival (branch build over itself)
runs-on: windows-2022
timeout-minutes: 50
env:
EXPECT: ${{ inputs.expect || 'survival' }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# This job only builds and runs the harness locally; it never pushes.
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Why: cache the built installer keyed on the inputs that affect it, so a
# harness-only edit skips the ~20 min electron-builder build. The daemon
# relocation code lives under src/, so changing it correctly rebuilds.
- name: Cache branch installer
id: cache-installer
uses: actions/cache@v4
with:
path: dist/orca-windows-setup.exe
key: branch-installer-${{ hashFiles('src/**', 'config/**', 'package.json', 'pnpm-lock.yaml') }}
- name: Build Windows installer (unsigned)
if: steps.cache-installer.outputs.cache-hit != 'true'
run: |
node config/scripts/ensure-native-runtime.mjs --runtime=electron
pnpm run build:desktop
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never
# Why: install the branch build, open a terminal, update the SAME build
# over it, and assert the relocated daemon survives. Same build on both
# sides isolates the survival mechanism (NSIS kill sweep + userData daemon
# + adoption) from cross-version staleness policy. No --install-dir: a CI
# runner has no real Orca to protect.
- name: Run survival harness
id: harness
shell: pwsh
env:
ORCA_E2E_DIAG_DIR: artifacts/diag
run: |
New-Item -ItemType Directory -Force artifacts | Out-Null
$exe = "dist/orca-windows-setup.exe"
if (-not (Test-Path $exe)) { throw "Installer not found at $exe" }
$log = "artifacts/survival-output.log"
node tools/win-update-e2e/run.mjs `
--from "$exe" `
--to "$exe" `
--expect "$env:EXPECT" `
--soak-seconds 60 2>&1 | Tee-Object -FilePath $log
exit $LASTEXITCODE
- name: Upload survival output
if: always()
uses: actions/upload-artifact@v7
with:
name: win-update-survival-output
path: |
artifacts/survival-output.log
artifacts/diag/**
retention-days: 7
if-no-files-found: warn
@@ -0,0 +1,360 @@
# Windows inner-binary signing rehearsal.
#
# Why: SignPath cannot deep-sign inside NSIS installers, so shipping signed
# inner binaries (Orca.exe, node-pty *.node, DLLs — see issue #7785) requires
# a two-request flow: sign the unpacked PE files first, then build the NSIS
# installer from the signed tree, then sign the installer. This workflow
# rehearses that entire flow from a branch, end to end, without publishing
# anything — so the release pipeline on main is never at risk while we verify.
#
# Runs only via manual dispatch. Use the test-signing policy for iteration
# (auto-approved test certificate) and release-signing to rehearse the
# production flow (requires manual SignPath approvals). Rehearsed successfully
# with test-signing (run 28987534795) and release-signing (run 28988432001).
name: Windows signing rehearsal
on:
workflow_dispatch:
inputs:
signing-policy-slug:
description: SignPath signing policy
type: choice
default: test-signing
options:
- test-signing
- release-signing
inner-artifact-configuration-slug:
description: SignPath artifact configuration for the inner-binaries zip
type: string
default: windows-inner-binaries-zip
jobs:
rehearse:
# Why: SignPath origin verification requires GitHub-hosted runners, and
# windows-2022 matches the real release job's build environment.
runs-on: windows-2022
timeout-minutes: 360
permissions:
actions: read
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# Why: nothing here pushes; keep the token out of .git/config.
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Cache electron-builder downloads
uses: actions/cache@v5
with:
path: |
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
key: electron-builder-win-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
electron-builder-win-
- name: Install dependencies
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile
# Why: rehearsal builds are never published, so the official-build
# secrets (telemetry key, diagnostics URL) are intentionally omitted.
- name: Build app
run: pnpm build:release
env:
NODE_OPTIONS: --max-old-space-size=4096
- name: Package unpacked Windows app
shell: pwsh
run: |
node config/scripts/ensure-native-runtime.mjs --runtime=electron
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --dir --publish never
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (-not (Test-Path 'dist/win-unpacked/Orca.exe')) {
throw 'electron-builder --dir did not produce dist/win-unpacked/Orca.exe'
}
# Why: only unsigned PE files go to SignPath. Files that already carry a
# valid signature (Microsoft's OpenConsole.exe, signed vendor DLLs) must
# keep their original signer, so they are excluded from the request.
- name: Stage unsigned PE files for inner signing
id: stage-inner
shell: pwsh
run: |
$root = Resolve-Path 'dist/win-unpacked'
$stage = New-Item -ItemType Directory -Force -Path 'signing-stage'
$list = New-Object System.Collections.Generic.List[string]
$skipped = New-Object System.Collections.Generic.List[string]
Get-ChildItem -Path $root -Recurse -File |
Where-Object { $_.Extension -in '.exe', '.dll', '.node' } |
ForEach-Object {
$relative = [System.IO.Path]::GetRelativePath($root, $_.FullName)
$signature = Get-AuthenticodeSignature -FilePath $_.FullName
if ($signature.Status -eq 'Valid') {
$skipped.Add("$relative <already signed: $($signature.SignerCertificate.Subject)>")
return
}
$destination = Join-Path $stage.FullName $relative
New-Item -ItemType Directory -Force -Path (Split-Path $destination) | Out-Null
Copy-Item -Path $_.FullName -Destination $destination -Force
$list.Add($relative)
}
if (-not ($list -contains 'Orca.exe')) {
throw 'Orca.exe was not staged for signing; unpacked layout changed?'
}
if (-not ($list | Where-Object { $_ -like '*conpty_console_list.node' })) {
throw 'node-pty conpty_console_list.node was not staged; this is the file from issue #7785.'
}
Set-Content -Path 'inner-signing-list.txt' -Value ($list -join "`n")
Write-Host "Staged $($list.Count) unsigned PE files for signing:"
$list | ForEach-Object { Write-Host " $_" }
Write-Host "Skipped $($skipped.Count) already-signed files:"
$skipped | ForEach-Object { Write-Host " $_" }
- name: Upload unsigned inner binaries for SignPath
id: upload-unsigned-inner
uses: actions/upload-artifact@v7
with:
name: orca-windows-inner-unsigned-${{ github.run_id }}
path: signing-stage/**
if-no-files-found: error
- name: Install SignPath PowerShell module
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue)
if ($useResourceGet) {
if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
Register-PSResourceRepository -PSGallery -Trusted
} else {
Set-PSResourceRepository -Name PSGallery -Trusted
}
Install-PSResource -Name SignPath -Version '[4.0.0,5.0.0)' -Repository PSGallery -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop
} else {
Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
}
Import-Module SignPath -ErrorAction Stop
Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop
- name: Submit inner binaries signing request
id: submit-inner-signing
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0
project-slug: orca
signing-policy-slug: ${{ inputs.signing-policy-slug || 'test-signing' }}
artifact-configuration-slug: ${{ inputs.inner-artifact-configuration-slug || 'windows-inner-binaries-zip' }}
github-artifact-id: ${{ steps.upload-unsigned-inner.outputs.artifact-id }}
wait-for-completion: false
- name: Download signed inner binaries from SignPath
shell: pwsh
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
run: |
Get-SignedArtifact `
-ApiToken $env:SIGNPATH_API_TOKEN `
-OrganizationId 'c37aa192-a27a-4377-9c90-5d6c95912dc0' `
-SigningRequestId $env:SIGNPATH_REQUEST_ID `
-OutputArtifactPath signed-inner.zip `
-Force `
-WaitForCompletionTimeoutInSeconds 3600
New-Item -ItemType Directory -Path signed-inner -Force
Expand-Archive -Path signed-inner.zip -DestinationPath signed-inner -Force
# Why: copy back strictly by the staged list so a layout mismatch in the
# returned artifact fails loudly instead of silently shipping a mix of
# signed and unsigned binaries.
- name: Restore signed inner binaries into unpacked app
shell: pwsh
run: |
$root = Resolve-Path 'dist/win-unpacked'
$failures = New-Object System.Collections.Generic.List[string]
foreach ($relative in Get-Content 'inner-signing-list.txt') {
$signed = Get-ChildItem -Path signed-inner -Recurse -File |
Where-Object { [System.IO.Path]::GetRelativePath((Resolve-Path 'signed-inner'), $_.FullName).TrimStart('\', '/') -like "*$relative" } |
Select-Object -First 1
if ($null -eq $signed) {
$failures.Add("missing from signed artifact: $relative")
continue
}
$signature = Get-AuthenticodeSignature -FilePath $signed.FullName
if ($null -eq $signature.SignerCertificate) {
$failures.Add("returned without a signature: $relative")
continue
}
Copy-Item -Path $signed.FullName -Destination (Join-Path $root $relative) -Force
Write-Host ("{0,-14} {1} <{2}>" -f $signature.Status, $relative, $signature.SignerCertificate.Subject)
}
if ($failures.Count -gt 0) {
$failures | ForEach-Object { Write-Host "::error::$_" }
throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)."
}
- name: Build NSIS installer from signed unpacked app
shell: pwsh
run: |
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never --prepackaged "$env:GITHUB_WORKSPACE\dist\win-unpacked"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (-not (Test-Path 'dist/orca-windows-setup.exe')) {
throw 'electron-builder --prepackaged did not produce dist/orca-windows-setup.exe'
}
- name: Upload unsigned Windows installer for SignPath
id: upload-unsigned-installer
uses: actions/upload-artifact@v7
with:
name: orca-windows-installer-unsigned-${{ github.run_id }}
path: dist/orca-windows-setup.exe
if-no-files-found: error
- name: Submit Windows installer signing request
id: submit-installer-signing
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0
project-slug: orca
signing-policy-slug: ${{ inputs.signing-policy-slug || 'test-signing' }}
artifact-configuration-slug: github-actions-windows-installer
github-artifact-id: ${{ steps.upload-unsigned-installer.outputs.artifact-id }}
wait-for-completion: false
- name: Download signed Windows installer from SignPath
shell: pwsh
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
SIGNPATH_REQUEST_ID: ${{ steps.submit-installer-signing.outputs.signing-request-id }}
run: |
Get-SignedArtifact `
-ApiToken $env:SIGNPATH_API_TOKEN `
-OrganizationId 'c37aa192-a27a-4377-9c90-5d6c95912dc0' `
-SigningRequestId $env:SIGNPATH_REQUEST_ID `
-OutputArtifactPath signed-windows.zip `
-Force `
-WaitForCompletionTimeoutInSeconds 14400
New-Item -ItemType Directory -Path signed-windows -Force
Expand-Archive -Path signed-windows.zip -DestinationPath signed-windows -Force
# Why: signing changes installer bytes, so the updater metadata must be
# regenerated exactly like the release job's staging step does.
- name: Stage signed Windows installer and regenerate updater metadata
shell: pwsh
run: |
$signedInstaller = Get-ChildItem -Path signed-windows -Recurse -File -Filter 'orca-windows-setup.exe' | Select-Object -First 1
if ($null -eq $signedInstaller) {
throw 'Signed Windows installer was not returned by SignPath.'
}
Copy-Item -Path $signedInstaller.FullName -Destination 'dist/orca-windows-setup.exe' -Force
& 'node_modules/app-builder-bin/win/x64/app-builder.exe' blockmap --input 'dist/orca-windows-setup.exe' --output 'dist/orca-windows-setup.exe.blockmap'
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$installer = Get-Item 'dist/orca-windows-setup.exe'
$blockmap = Get-Item 'dist/orca-windows-setup.exe.blockmap'
$stream = [System.IO.File]::OpenRead($installer.FullName)
try {
$sha512 = [System.Security.Cryptography.SHA512]::Create()
$hash = [Convert]::ToBase64String($sha512.ComputeHash($stream))
} finally {
if ($null -ne $sha512) { $sha512.Dispose() }
$stream.Dispose()
}
$latestYml = Get-Content -Path 'dist/latest.yml' -Raw
$latestYml = [regex]::Replace($latestYml, '(?m)^(\s*)sha512: .+$', {
param($match)
"$($match.Groups[1].Value)sha512: $hash"
})
$latestYml = $latestYml -replace '(?m)^ size: \d+$', " size: $($installer.Length)"
$latestYml = $latestYml -replace '(?m)^ blockMapSize: \d+$', " blockMapSize: $($blockmap.Length)"
Set-Content -Path 'dist/latest.yml' -Value $latestYml -NoNewline
# Why: this is the pass/fail heart of the rehearsal — the same evidence
# check that will later gate releases (see PR #7170). Test certificates
# do not chain to a trusted root, so Status=Valid is only required for
# release-signing runs; test-signing runs require a present signature.
- name: Verify signatures end to end
shell: pwsh
env:
SIGNING_POLICY: ${{ inputs.signing-policy-slug || 'test-signing' }}
run: |
$report = New-Object System.Collections.Generic.List[string]
$failures = New-Object System.Collections.Generic.List[string]
$requireValid = $env:SIGNING_POLICY -eq 'release-signing'
function Test-Signature([string]$label, [string]$path) {
$signature = Get-AuthenticodeSignature -FilePath $path
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
$line = "{0,-14} {1} <{2}>" -f $signature.Status, $label, $subject
$script:report.Add($line)
Write-Host $line
if ($null -eq $signature.SignerCertificate -or $signature.Status -eq 'NotSigned') {
$script:failures.Add("unsigned: $label")
} elseif ($script:requireValid -and $signature.Status -ne 'Valid') {
$script:failures.Add("not Valid under release-signing: $label ($($signature.Status))")
} elseif ($script:requireValid -and $subject -notlike '*CN=SignPath Foundation*') {
$script:failures.Add("unexpected signer: $label ($subject)")
}
}
Test-Signature 'installer: orca-windows-setup.exe' 'dist/orca-windows-setup.exe'
# Extract the signed installer and verify the files a user actually
# gets on disk — including the exact file from issue #7785.
$7za = 'node_modules/7zip-bin/win/x64/7za.exe'
New-Item -ItemType Directory -Path extracted-app -Force | Out-Null
& $7za x 'dist/orca-windows-setup.exe' '-oextracted-app' -y | Out-Null
$root = Resolve-Path 'extracted-app'
foreach ($relative in Get-Content 'inner-signing-list.txt') {
$path = Join-Path $root $relative
if (-not (Test-Path $path)) {
$failures.Add("missing from installer payload: $relative")
continue
}
Test-Signature "installed: $relative" $path
}
Set-Content -Path 'signing-evidence.txt' -Value ($report -join "`n")
if ($failures.Count -gt 0) {
$failures | ForEach-Object { Write-Host "::error::$_" }
throw "Signing rehearsal failed with $($failures.Count) problems."
}
Write-Host "All $((Get-Content 'inner-signing-list.txt').Count) inner binaries plus the installer are signed."
- name: Upload rehearsal evidence and installer
if: always()
uses: actions/upload-artifact@v7
with:
name: windows-signing-rehearsal-${{ inputs.signing-policy-slug || 'test-signing' }}-${{ github.run_id }}
path: |
signing-evidence.txt
inner-signing-list.txt
dist/orca-windows-setup.exe
dist/orca-windows-setup.exe.blockmap
dist/latest.yml
retention-days: 7
+1
View File
@@ -62,6 +62,7 @@ coverage/
# Benchmark run output
/tools/benchmarks/results/*.json
/.bench-fixtures/
# Temp
tmp/
+3 -1
View File
@@ -12,7 +12,9 @@ Keep comments short — one or two lines. Capture only the non-obvious reason (s
## Lint Rules: Do Not Disable Max Lines
Never add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants). Split the file, extract focused modules, move fixtures/builders into named files, or otherwise reduce the counted lines instead.
Never add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants), and never add a per-file `max-lines` bump in `mobile/.oxlintrc.json`. Split the file, extract focused modules, move fixtures/builders into named files, or otherwise reduce the counted lines instead.
This is enforced in CI by `pnpm check:max-lines-ratchet` (`config/scripts/check-max-lines-ratchet.mjs`). Files already over the limit are grandfathered in `config/max-lines-baseline.txt`; that list may only **shrink**. Adding a new suppression fails the build. If you remove a suppression (great — split it!), run `pnpm check:max-lines-ratchet --prune` to drop its baseline line.
## File and Module Naming
+4 -3
View File
@@ -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.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/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.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -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.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
- **Android:** [Download APK 0.0.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk)
---
@@ -238,9 +238,10 @@ Pair with your desktop app to monitor and steer your agents from your phone.
- **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements.
- **WeChat:** Scan the QR code to join the community.
- **WeChat:** Scan the QR code to join the community. If the first group is full, use the backup group.
<img src="docs/assets/wechat-qr.png" alt="WeChat QR code for the Orca community" width="160" />
<img src="docs/assets/wechat-qr-backup.jpg" alt="Backup WeChat QR code for the Orca community" width="160" />
- **Feedback &amp; Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
- **Privacy:** See the [privacy &amp; telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
+130
View File
@@ -0,0 +1,130 @@
import { spawnSync } from 'node:child_process'
import { join } from 'node:path'
import type { NormalizedOutputOptions, OutputBundle, OutputChunk, Plugin } from 'rollup'
// Why: v1.4.129-rc.1 shipped a dead terminal daemon because a shared main
// chunk gained `require("electron")` (an import edge added in #7642), and the
// daemon is forked as a plain-Node process where electron cannot be required.
// Nothing in CI executes the built daemon-entry under plain Node, so the leak
// stayed invisible until an adopted old daemon died. This guard fails the
// build when any chunk reachable from a plain-Node fork entry requires
// electron, and smoke-loads daemon-entry under plain Node to prove its module
// graph still resolves.
// Entries executed as plain Node (ELECTRON_RUN_AS_NODE / no electron runtime):
// forked daemon, parcel-watcher and computer sidecars, and the CLI-run
// agent-hooks entry. require("electron") throws MODULE_NOT_FOUND in all of them.
const PLAIN_NODE_ENTRY_NAMES = [
'daemon-entry',
'parcel-watcher-process-entry',
'computer-sidecar',
'agent-hooks/managed-agent-hook-controls'
] as const
const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/
function collectReachableChunks(
entry: OutputChunk,
byFileName: Map<string, OutputChunk>
): OutputChunk[] {
const seen = new Set<string>()
const reachable: OutputChunk[] = []
const stack = [entry.fileName]
while (stack.length > 0) {
const fileName = stack.pop() as string
if (seen.has(fileName)) {
continue
}
seen.add(fileName)
const chunk = byFileName.get(fileName)
if (!chunk) {
continue
}
reachable.push(chunk)
for (const imported of [...chunk.imports, ...chunk.dynamicImports]) {
stack.push(imported)
}
}
return reachable
}
function assertNoElectronRequire(
entryName: string,
entry: OutputChunk,
byFileName: Map<string, OutputChunk>
): void {
for (const chunk of collectReachableChunks(entry, byFileName)) {
if (ELECTRON_REQUIRE_RE.test(chunk.code)) {
throw new Error(
`[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` +
`requires electron. "${entryName}" runs as a plain-Node process, where ` +
`require("electron") throws MODULE_NOT_FOUND and kills it at startup (the ` +
`v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.`
)
}
}
}
// Why: proves the whole daemon-entry graph resolves under plain Node (no
// unresolved requires). require("electron") does not throw in a dev tree with
// node_modules present, so the static scan above — not this smoke — is the
// electron regression guard; this only catches gross load failures.
function smokeLoadDaemonEntry(outputDir: string): void {
const entryPath = join(outputDir, 'daemon-entry.js')
const result = spawnSync(process.execPath, [entryPath], {
encoding: 'utf8',
timeout: 15_000
})
if (result.error) {
throw new Error(
`[plain-node-entry-guard] could not smoke-load daemon-entry.js under plain Node: ` +
`${result.error.message}`
)
}
const stderr = result.stderr ?? ''
if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) {
throw new Error(
`[plain-node-entry-guard] daemon-entry.js failed to load under plain Node:\n${stderr}`
)
}
if (!stderr.includes('Usage: daemon-entry')) {
throw new Error(
`[plain-node-entry-guard] daemon-entry.js did not reach argv parsing under plain Node ` +
`(expected the "Usage: daemon-entry" error). stderr:\n${stderr}`
)
}
}
export function createPlainNodeEntryGuardPlugin(): Plugin {
return {
name: 'orca-plain-node-entry-guard',
writeBundle(options: NormalizedOutputOptions, bundle: OutputBundle) {
// Why: skip in `electron-vite dev` watch mode — the smoke would respawn on
// every rebuild, and the guard only needs to gate produced builds.
if (this.meta.watchMode) {
return
}
const chunks = Object.values(bundle).filter(
(item): item is OutputChunk => item.type === 'chunk'
)
const byFileName = new Map(chunks.map((chunk) => [chunk.fileName, chunk]))
const entryByName = new Map<string, OutputChunk>()
for (const chunk of chunks) {
if (chunk.isEntry && chunk.name) {
entryByName.set(chunk.name, chunk)
}
}
for (const entryName of PLAIN_NODE_ENTRY_NAMES) {
const entry = entryByName.get(entryName)
if (entry) {
assertNoElectronRequire(entryName, entry, byFileName)
}
}
if (entryByName.has('daemon-entry') && options.dir) {
smokeLoadDaemonEntry(options.dir)
}
}
}
}
+65 -1
View File
@@ -2,6 +2,7 @@ const { chmodSync, existsSync, readdirSync } = require('node:fs')
const { execFileSync } = require('node:child_process')
const { join, resolve } = require('node:path')
const electronBuilderNativeRebuild = require('./scripts/electron-builder-native-rebuild.cjs')
const { verifyPackagedDaemonEntryBoots } = require('./scripts/verify-packaged-daemon-entry.cjs')
const {
createPackagedRuntimeNodeModuleResources,
prunePackagedRuntimeNodeModules,
@@ -106,6 +107,7 @@ module.exports = {
'out/main/win32-utils.js',
'out/main/daemon-entry.js',
'out/main/computer-sidecar.js',
'out/main/parcel-watcher-process-entry.js',
'out/main/chunks/**',
'resources/**',
'node_modules/ws/**',
@@ -129,6 +131,20 @@ module.exports = {
}
prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName, context.arch)
verifyPackagedMainRuntimeDeps(resourcesDir)
// Why: boot the packaged daemon-entry under plain Node, but only for the
// slice matching the packaging host's arch — daemon-entry.js is JS, yet it
// require()s the native (N-API) node-pty for the TARGET arch, which the host
// Node cannot load cross-arch. `Arch` enum: ia32=0, x64=1, armv7l=2,
// arm64=3, universal=4 (universal contains the host slice, so run it).
const archEnumByNodeArch = { ia32: 0, x64: 1, armv7l: 2, arm64: 3 }
const hostArchEnum = archEnumByNodeArch[process.arch]
if (context.arch === hostArchEnum || context.arch === 4) {
verifyPackagedDaemonEntryBoots(resourcesDir)
} else {
console.log(
`[verify-packaged-daemon-entry] skipped cross-arch slice (target ${context.arch}, host ${process.arch})`
)
}
chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName)
chmodMacServeSimHelpers(resourcesDir, context.electronPlatformName)
for (const filename of readdirSync(resourcesDir)) {
@@ -142,6 +158,10 @@ module.exports = {
}
if (context.electronPlatformName === 'darwin') {
await signMacComputerUseHelper(join(resourcesDir, 'Orca Computer Use.app'), context.packager)
await signMacNotificationStatusHelper(
join(resourcesDir, '..', 'MacOS', 'orca-notification-status'),
context.packager
)
}
},
win: {
@@ -173,7 +193,11 @@ module.exports = {
artifactName: 'orca-windows-setup.${ext}',
shortcutName: '${productName}',
uninstallDisplayName: '${productName}',
createDesktopShortcut: 'always'
createDesktopShortcut: 'always',
// Why: on a real uninstall, stop and remove the relocated terminal daemon
// (which lives outside the install dir under LOCALAPPDATA by design). Guarded
// by ${isUpdated} inside so it never runs during an update's uninstallOldVersion.
include: resolve(__dirname, 'nsis', 'daemon-host-uninstall.nsh')
},
mac: {
icon: 'resources/build/icon.icns',
@@ -229,6 +253,15 @@ module.exports = {
},
featureWallResources
],
// Why: the notification-status helper must execute from Contents/MacOS —
// on macOS 26 UNUserNotificationCenter aborts (bundleProxyForCurrentProcess
// is nil) for executables launched out of Contents/Resources (#7929).
extraFiles: [
{
from: 'native/notification-status-macos/.build/release/orca-notification-status',
to: 'MacOS/orca-notification-status'
}
],
target: [
{
target: 'dmg',
@@ -398,6 +431,37 @@ async function signMacComputerUseHelper(helperAppPath, packager) {
})
}
async function signMacNotificationStatusHelper(helperPath, packager) {
if (!existsSync(helperPath)) {
if (isMacRelease) {
throw new Error(`Missing orca-notification-status helper at ${helperPath}`)
}
return
}
const codeSigningInfo =
isMacRelease && process.env.CSC_LINK && packager?.codeSigningInfo?.value
? await packager.codeSigningInfo.value
: null
const identity =
process.env.CSC_NAME ??
findInstalledMacSigningIdentity(codeSigningInfo?.keychainFile) ??
(isMacRelease ? null : '-')
if (!identity) {
throw new Error('Missing signing identity for orca-notification-status helper')
}
// Why: macOS keys notification records to the code-signing identifier; the
// binary embeds the app's CFBundleIdentifier in __TEXT,__info_plist so this
// (and any later) `codesign --force` derives the correct identifier. Sign
// before the outer Orca.app is sealed, like the computer-use helper.
const args = ['--force', '--sign', identity]
if (isMacRelease) {
args.push('--options', 'runtime', '--timestamp')
}
args.push(helperPath)
execFileSync('codesign', args, { stdio: 'inherit' })
execFileSync('codesign', ['--verify', '--strict', helperPath], { stdio: 'inherit' })
}
function codesignArgs(identity, targetPath) {
const args = ['--force', '--deep', '--sign', identity]
if (isMacRelease) {
+359
View File
@@ -0,0 +1,359 @@
# Files/globs currently allowed to exceed the oxlint `max-lines` budget.
# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —
# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").
# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)
inline mobile/src/constants/marine-creatures.ts
inline src/cli/handlers/automations.ts
inline src/cli/handlers/orchestration.ts
inline src/cli/help.ts
inline src/cli/index.test.ts
inline src/main/agent-hooks/server.test.ts
inline src/main/agent-hooks/server.ts
inline src/main/amp/hook-service.ts
inline src/main/antigravity/hook-service.ts
inline src/main/attribution/terminal-attribution.ts
inline src/main/automations/external-manager.ts
inline src/main/automations/hermes-cron-output.ts
inline src/main/browser/agent-browser-bridge.test.ts
inline src/main/browser/agent-browser-bridge.ts
inline src/main/browser/browser-cookie-import.ts
inline src/main/browser/browser-guest-ui.ts
inline src/main/browser/browser-manager-grab.test.ts
inline src/main/browser/browser-manager.test.ts
inline src/main/browser/browser-manager.ts
inline src/main/browser/browser-screencast-stream.ts
inline src/main/browser/browser-session-registry.ts
inline src/main/browser/cdp-bridge.ts
inline src/main/browser/cdp-ws-proxy.ts
inline src/main/browser/grab-guest-script.ts
inline src/main/browser/snapshot-engine.ts
inline src/main/claude-accounts/runtime-auth-service.test.ts
inline src/main/claude-accounts/runtime-auth-service.ts
inline src/main/claude-accounts/service.test.ts
inline src/main/claude-accounts/service.ts
inline src/main/claude-usage/scanner.ts
inline src/main/claude-usage/store.ts
inline src/main/cli/cli-installer.test.ts
inline src/main/cli/cli-installer.ts
inline src/main/cli/wsl-cli-installer.ts
inline src/main/codex-accounts/runtime-home-service.test.ts
inline src/main/codex-accounts/runtime-home-service.ts
inline src/main/codex-accounts/service.test.ts
inline src/main/codex-accounts/service.ts
inline src/main/codex-usage/scanner.ts
inline src/main/codex-usage/store.test.ts
inline src/main/codex-usage/store.ts
inline src/main/codex/config-toml-trust.test.ts
inline src/main/codex/config-toml-trust.ts
inline src/main/codex/hook-service.test.ts
inline src/main/codex/hook-service.ts
inline src/main/copilot/hook-service.ts
inline src/main/daemon/client.ts
inline src/main/daemon/daemon-health.ts
inline src/main/daemon/daemon-init.test.ts
inline src/main/daemon/daemon-init.ts
inline src/main/daemon/daemon-pty-adapter.test.ts
inline src/main/daemon/daemon-pty-adapter.ts
inline src/main/daemon/daemon-server.ts
inline src/main/daemon/pty-subprocess.test.ts
inline src/main/daemon/pty-subprocess.ts
inline src/main/daemon/session.ts
inline src/main/daemon/shell-ready.ts
inline src/main/git/remove-worktree.test.ts
inline src/main/git/repo.ts
inline src/main/git/runner.ts
inline src/main/git/status.test.ts
inline src/main/git/status.ts
inline src/main/git/worktree.test.ts
inline src/main/git/worktree.ts
inline src/main/github/client.test.ts
inline src/main/github/client.ts
inline src/main/github/issues.ts
inline src/main/github/pr-refresh-coordinator.test.ts
inline src/main/github/pr-refresh-coordinator.ts
inline src/main/github/project-view.ts
inline src/main/github/project-view/mutations.ts
inline src/main/github/work-item-details.ts
inline src/main/gitlab/client-mr.test.ts
inline src/main/gitlab/client.ts
inline src/main/gitlab/issues.ts
inline src/main/gitlab/work-item-details.ts
inline src/main/hermes/hook-service.ts
inline src/main/hooks.test.ts
inline src/main/hooks.ts
inline src/main/index.ts
inline src/main/ipc/browser.ts
inline src/main/ipc/crash-reporting.ts
inline src/main/ipc/filesystem-auth.ts
inline src/main/ipc/filesystem-mutations.ts
inline src/main/ipc/filesystem-watcher.ts
inline src/main/ipc/filesystem.test.ts
inline src/main/ipc/filesystem.ts
inline src/main/ipc/github.test.ts
inline src/main/ipc/github.ts
inline src/main/ipc/gitlab.ts
inline src/main/ipc/linear.ts
inline src/main/ipc/notifications.test.ts
inline src/main/ipc/notifications.ts
inline src/main/ipc/pet.ts
inline src/main/ipc/preflight.test.ts
inline src/main/ipc/pty.test.ts
inline src/main/ipc/pty.ts
inline src/main/ipc/remote-workspace.ts
inline src/main/ipc/repos-remote.test.ts
inline src/main/ipc/repos.ts
inline src/main/ipc/runtime-environments.test.ts
inline src/main/ipc/ssh.test.ts
inline src/main/ipc/ssh.ts
inline src/main/ipc/worktree-remote.ts
inline src/main/ipc/worktrees.test.ts
inline src/main/ipc/worktrees.ts
inline src/main/jira/client.ts
inline src/main/jira/issues.ts
inline src/main/keybindings/keybinding-file.ts
inline src/main/linear/client.ts
inline src/main/linear/issues.ts
inline src/main/linear/projects.ts
inline src/main/memory/collector.ts
inline src/main/opencode-usage/scanner.ts
inline src/main/opencode-usage/store.ts
inline src/main/opencode/hook-service.ts
inline src/main/persistence.test.ts
inline src/main/persistence.ts
inline src/main/ports/advertised-url-watcher.ts
inline src/main/ports/local-workspace-port-scanner.ts
inline src/main/project-groups/nested-repo-discovery.ts
inline src/main/providers/local-pty-provider.test.ts
inline src/main/providers/local-pty-provider.ts
inline src/main/providers/local-pty-shell-ready.test.ts
inline src/main/providers/local-pty-shell-ready.ts
inline src/main/providers/ssh-git-provider.test.ts
inline src/main/providers/ssh-git-provider.ts
inline src/main/rate-limits/claude-fetcher.test.ts
inline src/main/rate-limits/claude-fetcher.ts
inline src/main/rate-limits/claude-pty.ts
inline src/main/rate-limits/codex-fetcher.ts
inline src/main/rate-limits/service.test.ts
inline src/main/rate-limits/service.ts
inline src/main/runtime/orca-runtime-browser.ts
inline src/main/runtime/orca-runtime-files.test.ts
inline src/main/runtime/orca-runtime-files.ts
inline src/main/runtime/orca-runtime-git.ts
inline src/main/runtime/orca-runtime.test.ts
inline src/main/runtime/orca-runtime.ts
inline src/main/runtime/orchestration/coordinator.ts
inline src/main/runtime/orchestration/db.ts
inline src/main/runtime/rpc/methods/files.ts
inline src/main/runtime/rpc/methods/git.ts
inline src/main/runtime/rpc/methods/github.ts
inline src/main/runtime/rpc/methods/orchestration.test.ts
inline src/main/runtime/rpc/methods/orchestration.ts
inline src/main/runtime/rpc/methods/terminal.ts
inline src/main/runtime/rpc/terminal-multiplex.test.ts
inline src/main/runtime/runtime-rpc.test.ts
inline src/main/runtime/runtime-rpc.ts
inline src/main/source-control/hosted-review-creation.ts
inline src/main/speech/model-manager.ts
inline src/main/speech/stt-service.ts
inline src/main/ssh/ssh-channel-multiplexer.ts
inline src/main/ssh/ssh-connection.test.ts
inline src/main/ssh/ssh-connection.ts
inline src/main/ssh/ssh-relay-deploy.ts
inline src/main/ssh/ssh-relay-session.ts
inline src/main/star-nag/service.test.ts
inline src/main/text-generation/commit-message-text-generation.test.ts
inline src/main/text-generation/commit-message-text-generation.ts
inline src/main/updater.test.ts
inline src/main/updater.ts
inline src/main/window/attach-main-window-services.ts
inline src/main/window/createMainWindow.test.ts
inline src/main/window/createMainWindow.ts
inline src/main/workspace-space-analysis.ts
inline src/preload/api-types.ts
inline src/preload/index.ts
inline src/relay/agent-hook-server.ts
inline src/relay/dispatcher.ts
inline src/relay/external-automations-handler.ts
inline src/relay/fs-handler.ts
inline src/relay/git-handler.test.ts
inline src/relay/git-handler.ts
inline src/relay/pty-handler.test.ts
inline src/relay/pty-handler.ts
inline src/relay/relay.ts
inline src/relay/workspace-space-scan.ts
inline src/renderer/src/App.tsx
inline src/renderer/src/components/GitHubItemDialog.tsx
inline src/renderer/src/components/GitLabItemDialog.tsx
inline src/renderer/src/components/JiraIssueWorkspace.tsx
inline src/renderer/src/components/LinearIssueWorkspace.tsx
inline src/renderer/src/components/LinearItemDrawer.tsx
inline src/renderer/src/components/NewWorkspaceComposerCard.tsx
inline src/renderer/src/components/PullRequestPage.tsx
inline src/renderer/src/components/TaskPage.tsx
inline src/renderer/src/components/Terminal.tsx
inline src/renderer/src/components/UpdateCard.tsx
inline src/renderer/src/components/WorktreeJumpPalette.tsx
inline src/renderer/src/components/activity/ActivityPrototypePage.test.ts
inline src/renderer/src/components/activity/ActivityPrototypePage.tsx
inline src/renderer/src/components/automations/AutomationsPage.tsx
inline src/renderer/src/components/browser-pane/BrowserPane.tsx
inline src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx
inline src/renderer/src/components/editor/CombinedDiffViewer.tsx
inline src/renderer/src/components/editor/EditorContent.tsx
inline src/renderer/src/components/editor/IpynbViewer.tsx
inline src/renderer/src/components/editor/MarkdownPreview.tsx
inline src/renderer/src/components/editor/MonacoEditor.tsx
inline src/renderer/src/components/editor/editor-autosave-controller.ts
inline src/renderer/src/components/editor/ipynb-parse.ts
inline src/renderer/src/components/editor/useEditorPanelContentState.ts
inline src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx
inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx
inline src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
inline src/renderer/src/components/github-project/ProjectCell.tsx
inline src/renderer/src/components/github-project/ProjectPicker.tsx
inline src/renderer/src/components/github-project/ProjectViewWrapper.tsx
inline src/renderer/src/components/linear-project-view-surfaces.tsx
inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
inline src/renderer/src/components/onboarding/use-onboarding-flow.ts
inline src/renderer/src/components/right-sidebar/ChecksPanel.tsx
inline src/renderer/src/components/right-sidebar/FileExplorer.test.tsx
inline src/renderer/src/components/right-sidebar/FileExplorer.tsx
inline src/renderer/src/components/right-sidebar/FileExplorerRow.tsx
inline src/renderer/src/components/right-sidebar/PortsPanel.tsx
inline src/renderer/src/components/right-sidebar/SourceControl.tsx
inline src/renderer/src/components/right-sidebar/checks-panel-content.tsx
inline src/renderer/src/components/right-sidebar/index.tsx
inline src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts
inline src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts
inline src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts
inline src/renderer/src/components/settings/AccountsPane.tsx
inline src/renderer/src/components/settings/AgentsPane.tsx
inline src/renderer/src/components/settings/RepositoryHooksSection.tsx
inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
inline src/renderer/src/components/settings/Settings.tsx
inline src/renderer/src/components/settings/SettingsFormControls.tsx
inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx
inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
inline src/renderer/src/components/sidebar/WorktreeCard.tsx
inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
inline src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts
inline src/renderer/src/components/sidebar/WorktreeList.tsx
inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts
inline src/renderer/src/components/sidebar/worktree-list-groups.test.ts
inline src/renderer/src/components/sidebar/worktree-list-groups.ts
inline src/renderer/src/components/stats/usage-overview-model.ts
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
inline src/renderer/src/components/status-bar/StatusBar.tsx
inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
inline src/renderer/src/components/tab-bar/TabBar.tsx
inline src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts
inline src/renderer/src/components/tab-group/useTabDragSplit.ts
inline src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts
inline src/renderer/src/components/task-page-cache-selectors.ts
inline src/renderer/src/components/terminal-pane/TerminalPane.tsx
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts
inline src/renderer/src/components/terminal-pane/keyboard-handlers.ts
inline src/renderer/src/components/terminal-pane/pty-connection.test.ts
inline src/renderer/src/components/terminal-pane/pty-connection.ts
inline src/renderer/src/components/terminal-pane/pty-transport.test.ts
inline src/renderer/src/components/terminal-pane/pty-transport.ts
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
inline src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
inline src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx
inline src/renderer/src/hooks/useAutomationDispatchEvents.ts
inline src/renderer/src/hooks/useComposerState.ts
inline src/renderer/src/hooks/useEditorExternalWatch.ts
inline src/renderer/src/hooks/useIpcEvents.test.ts
inline src/renderer/src/hooks/useIpcEvents.ts
inline src/renderer/src/hooks/useIssueMetadata.ts
inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts
inline src/renderer/src/lib/active-agent-note-send.test.ts
inline src/renderer/src/lib/file-type-icons.ts
inline src/renderer/src/lib/pane-manager/pane-manager.ts
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts
inline src/renderer/src/lib/terminal-links.ts
inline src/renderer/src/lib/worktree-activation.test.ts
inline src/renderer/src/lib/worktree-activation.ts
inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
inline src/renderer/src/runtime/runtime-file-client.test.ts
inline src/renderer/src/runtime/runtime-file-client.ts
inline src/renderer/src/runtime/runtime-git-client.ts
inline src/renderer/src/runtime/runtime-linear-client.ts
inline src/renderer/src/runtime/sync-runtime-graph.test.ts
inline src/renderer/src/runtime/sync-runtime-graph.ts
inline src/renderer/src/runtime/web-runtime-session.test.ts
inline src/renderer/src/runtime/web-runtime-session.ts
inline src/renderer/src/runtime/web-session-tabs-sync.test.ts
inline src/renderer/src/runtime/web-session-tabs-sync.ts
inline src/renderer/src/store/slices/agent-status.test.ts
inline src/renderer/src/store/slices/agent-status.ts
inline src/renderer/src/store/slices/browser.test.ts
inline src/renderer/src/store/slices/browser.ts
inline src/renderer/src/store/slices/diffComments.ts
inline src/renderer/src/store/slices/editor.test.ts
inline src/renderer/src/store/slices/editor.ts
inline src/renderer/src/store/slices/github.test.ts
inline src/renderer/src/store/slices/github.ts
inline src/renderer/src/store/slices/hosted-review.ts
inline src/renderer/src/store/slices/jira.ts
inline src/renderer/src/store/slices/linear.test.ts
inline src/renderer/src/store/slices/linear.ts
inline src/renderer/src/store/slices/repos.ts
inline src/renderer/src/store/slices/store-cascades.test.ts
inline src/renderer/src/store/slices/store-session-cascades.test.ts
inline src/renderer/src/store/slices/tabs.test.ts
inline src/renderer/src/store/slices/tabs.ts
inline src/renderer/src/store/slices/terminals.ts
inline src/renderer/src/store/slices/ui.test.ts
inline src/renderer/src/store/slices/ui.ts
inline src/renderer/src/store/slices/workspace-cleanup.ts
inline src/renderer/src/store/slices/worktrees.test.ts
inline src/renderer/src/store/slices/worktrees.ts
inline src/renderer/src/web/web-preload-api.test.ts
inline src/renderer/src/web/web-preload-api.ts
inline src/renderer/src/web/web-runtime-client.ts
inline src/shared/agent-hook-listener.test.ts
inline src/shared/agent-hook-listener.ts
inline src/shared/automation-schedules.ts
inline src/shared/commit-message-agent-spec.ts
inline src/shared/constants.ts
inline src/shared/github-project-types.ts
inline src/shared/keybindings.test.ts
inline src/shared/keybindings.ts
inline src/shared/marine-creatures.ts
inline src/shared/remote-runtime-client.ts
inline src/shared/runtime-types.ts
inline src/shared/source-control-ai.ts
inline src/shared/telemetry-events.ts
inline src/shared/text-search.ts
inline src/shared/types.ts
inline tests/e2e/helpers/terminal.ts
inline tests/e2e/terminal-panes.spec.ts
mobile-config app/h/*/files/*.tsx
mobile-config app/h/*/index.tsx
mobile-config app/h/*/session/*.tsx
mobile-config app/h/*/source-control/*.tsx
mobile-config app/h/*/tasks.tsx
mobile-config app/index.tsx
mobile-config app/pair-scan.tsx
mobile-config app/terminal-settings.tsx
mobile-config app/troubleshoot.tsx
mobile-config scripts/mock-server.ts
mobile-config scripts/repro-terminal-colors.ts
mobile-config scripts/repro-worktree-startup-stream.ts
mobile-config src/browser/MobileBrowserPane.tsx
mobile-config src/components/CustomKeyModal.tsx
mobile-config src/components/NewWorktreeModal.tsx
mobile-config src/components/mobile-rich-markdown-editor-html.ts
mobile-config src/terminal/terminal-accessory-keys.ts
mobile-config src/terminal/terminal-webview-html.ts
mobile-config src/transport/rpc-client.ts
+23
View File
@@ -0,0 +1,23 @@
; Clean up the relocated terminal daemon on a REAL uninstall.
;
; Why: the daemon host is deliberately copied to a distinct image name
; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app
; UPDATES cannot kill it — that relocation is what keeps terminals alive across
; updates. The same design means a normal uninstall's process sweep and file
; removal both miss it, leaving an orphaned daemon plus its runtime copy behind.
;
; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as
; part of uninstallOldVersion on EVERY update, and killing the daemon there would
; defeat the whole feature. Only clean up on a genuine uninstall.
;
; The image name and the LOCALAPPDATA folder name must stay in sync with
; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in
; src/main/daemon/daemon-host-relocation.ts.
!macro customUnInstall
${ifNot} ${isUpdated}
nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe'
; Give the OS a moment to release the image lock before removing the tree.
Sleep 500
RMDir /r "$LOCALAPPDATA\Orca\daemon-host"
${endIf}
!macroend
+454 -16
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-07-03",
"updatedAt": "2026-07-08",
"policy": {
"maturityLevels": [
"experimental",
@@ -105,6 +105,163 @@
],
"demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner."
},
{
"id": "terminal-session.startup-cwd-missing-dir-recovery",
"title": "Fresh local terminal creation cannot be bricked by a deleted startup cwd",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-runtime",
"layer": "shared-main-renderer-contract",
"surfaces": [
"terminal lifecycle",
"tab creation",
"PTY spawn",
"startup cwd persistence"
],
"platforms": [
"macos",
"linux",
"windows",
"mobile"
],
"providers": [
"local",
"daemon",
"ssh",
"wsl",
"remote-runtime"
],
"coveredPlatforms": [
"macos"
],
"coveredProviders": [
"local",
"ssh",
"remote-runtime"
],
"coverageNotes": "Local macOS evidence covers the shared missing-dir fallback policy, main pty:spawn recovery and metadata, no-flag and reattach strictness, renderer IPC flag routing, SSH-tagged and remote-runtime omission, and the visibility-gated terminal notice. Daemon shares the same pre-provider main cwd decision but lacks a live daemon-provider run; WSL UNC paths are exempt from the probe by design and lack a live run; Linux/Windows and mobile/API strictness are gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/7239",
"https://github.com/stablyai/orca/pull/7750",
"https://github.com/stablyai/orca/pull/7678"
],
"invariant": "A fresh local renderer terminal spawn may recover from a saved startup cwd whose directory no longer exists only by spawning at the selected workspace root and printing a generic in-terminal notice; existing directories — including ones outside the worktree (#7685) — spawn as requested, and reattach, SSH, remote-runtime, runtime/API, and mobile callers keep exact cwd semantics.",
"oracle": "The shared resolver falls back to the workspace root only when the injected existence probe reports the resolved cwd missing and the workspace root present, and never probes floating terminals or a cwd equal to the root. The renderer sends cwdFallback only for fresh local IPC spawns, main honors it only when connectionId and sessionId are absent, WSL UNC paths never engage the probe-based fallback, main returns fallback metadata only after an actual fallback, the IPC transport preserves that metadata, and the connection layer writes a generic notice that omits the missing path.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/shared/terminal-startup-cwd.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts"
],
"testFiles": [
"src/shared/terminal-startup-cwd.test.ts",
"src/main/ipc/pty.test.ts",
"src/renderer/src/components/terminal-pane/pty-transport.test.ts",
"src/renderer/src/components/terminal-pane/pty-connection.test.ts"
],
"assertionRefs": [
{
"file": "src/shared/terminal-startup-cwd.test.ts",
"assertions": [
"a missing requested cwd falls back to the workspace root and reports the missing path to the callback",
"existing cwds — nested or outside the worktree (#7685) — are never remapped",
"no fallback happens when the workspace root is missing too",
"floating terminal cwds and root-equal requests are never probed",
"non-ASCII worktree roots and folder workspace roots are recovered verbatim"
]
},
{
"file": "src/main/ipc/pty.test.ts",
"assertions": [
"local pty:spawn with cwdFallback worktree spawns at the worktree root when the saved cwd is missing and returns fallback metadata",
"a missing cwd without the flag still surfaces the provider's missing-directory error",
"an existing outside-worktree cwd spawns as requested without fallback metadata",
"session reattach spawns ignore the fallback flag and keep exact cwd semantics"
]
},
{
"file": "src/renderer/src/components/terminal-pane/pty-transport.test.ts",
"assertions": [
"IPC transport sends cwdFallback only for local fresh spawns",
"SSH-tagged and session reattach spawns omit cwdFallback",
"IPC transport returns startup cwd fallback metadata to the connection layer"
]
},
{
"file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"assertions": [
"fresh local IPC worktree spawns are marked with cwdFallback worktree",
"startup cwd fallback metadata prints a generic in-terminal notice",
"remote-runtime worktree spawns are not marked with cwdFallback"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-08",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/terminal-startup-cwd.test.ts",
"result": "passed",
"durationSeconds": 0.2,
"summary": "1 test file passed, 21 tests passed; covers the missing-dir fallback policy, #7685 outside-worktree preservation, and root-missing/floating exemptions."
},
{
"date": "2026-07-08",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts",
"result": "passed",
"durationSeconds": 0.9,
"summary": "1 test file passed, 225 tests passed; covers main pty:spawn recovery, fallback metadata, and no-flag/reattach provider-error strictness."
},
{
"date": "2026-07-08",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts",
"result": "passed",
"durationSeconds": 0.4,
"summary": "1 test file passed, 58 tests passed; covers cwdFallback forwarding only for local fresh spawns and metadata handoff."
},
{
"date": "2026-07-08",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"result": "passed",
"durationSeconds": 6.5,
"summary": "1 test file passed, 341 tests passed; covers local IPC marking, the generic terminal fallback notice, and remote-runtime omission."
}
],
"runtimeBudget": {
"p95Seconds": 30,
"scope": "focused unit and IPC contract tests"
},
"flakeHistory": {
"status": "unknown",
"evidence": "New experimental gate added with local deterministic evidence only; needs CI soak before promotion."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "The main IPC missing-cwd tests fail with the provider's 'Working directory ... does not exist.' error when the fallback is removed and pass with it. Full live Electron reproduction from a production persisted session is not captured."
},
"performanceBudget": {
"required": true,
"evidence": "The runtime change adds at most two statSync probes on the fresh-local spawn path (the provider already stats the same paths during validation) and one bounded terminal write only when fallback actually occurs; no polling, provider listing, hidden-pane work, startup awaits, subprocesses, or render-loop work was added."
},
"promotionCriteria": [
"Attach CI evidence for all declared test files.",
"Add a live Electron regression that opens a local terminal whose persisted startupCwd was deleted and proves visible shell input/output at the workspace root.",
"Add WSL/mobile/API provider-contract coverage or explicitly narrow their risk scope."
],
"knownGaps": [
"No live Electron fixture seeds a persisted tab whose startupCwd directory was deleted.",
"Daemon coverage is via the shared pre-provider main cwd decision, not a live daemon provider spawn.",
"WSL UNC paths bypass the probe by design and have no live existence-recovery run; Linux, Windows, and mobile/API strictness are not directly exercised."
],
"demotionRule": "Demote or quarantine if the gate flakes without a product bug, if an existing directory is ever remapped away from the requested cwd, or if a reattach/remote/API caller can engage the fallback."
},
{
"id": "agent-session.provider-ownership",
"title": "Provider sessions are resumed once per workspace ownership claim",
@@ -458,6 +615,97 @@
],
"demotionRule": "Cannot promote while it only checks visual rendering."
},
{
"id": "terminal-scroll.intent-preservation",
"title": "Scrollbar drag intent survives tab and visibility resume",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-rendering",
"layer": "renderer-unit",
"surfaces": [
"terminal lifecycle",
"hidden-to-visible resume",
"tab switching",
"xterm scrollbar DOM",
"scrollback"
],
"platforms": [
"macos",
"linux",
"windows"
],
"providers": [
"local",
"daemon",
"ssh",
"wsl",
"remote-runtime"
],
"coveredPlatforms": [
"macos"
],
"coveredProviders": [],
"coverageNotes": "Renderer-unit coverage proves the shared xterm DOM intent path. Live Electron evidence is PR validation evidence for local macOS only until the flow has stable automation; live SSH, WSL, Linux, and Windows paths remain unproved.",
"motivatingLinks": [
"STA-1341"
],
"invariant": "A user-driven xterm scrollbar thumb or track scroll updates the live terminal scroll intent before tab, visibility, or layout resume enforces intent, so resume preserves the latest dragged viewport instead of an older pinned line.",
"oracle": "Pointerdown on .xterm-scrollbar or .xterm-slider followed by xterm viewport movement records the new pinned viewport, and enforcing current intent restores that dragged line instead of stale top intent.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts"
],
"testFiles": [
"src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts",
"assertions": [
"pointer-driven .xterm-scrollbar and .xterm-slider scrolls update terminal scroll intent",
"a scrollbar-dragged viewport is restored instead of stale top intent",
"terminal body pointer activity is not treated as scrollbar intent"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-05",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts",
"result": "passed",
"durationSeconds": 0.3,
"summary": "1 test file(s) passed, 17 tests passed in this worktree."
}
],
"runtimeBudget": {
"p95Seconds": 10,
"scope": "focused renderer unit test"
},
"flakeHistory": {
"status": "unknown",
"evidence": "New experimental gate; needs soak history before promotion."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "The new scrollbar DOM target assertions fail when only .xterm-viewport pointerdown is recognized. Needs saved CI/intentional-break artifact before blocking promotion."
},
"performanceBudget": {
"required": true,
"evidence": "Runtime fix is limited to constant-time class/closest checks on pointerdown. It must not add polling, output parsing, PTY listing, hidden-pane wakeups, resize calls, or startup awaits."
},
"promotionCriteria": [
"Collect stable CI soak history for the renderer-unit gate.",
"Add stable live Electron automation for the scrollbar drag plus tab-switch repro.",
"Attach red/green evidence for stale intent restoring the wrong viewport."
],
"knownGaps": [
"Manifest command is deterministic renderer-unit coverage, not a live Electron scrollbar drag.",
"Live daemon, SSH, WSL, remote-runtime, Linux, and Windows validation is not covered by this gate.",
"Future xterm scrollbar DOM class changes are only caught if they break the currently modeled class contract."
],
"demotionRule": "Demote or quarantine if the unit gate flakes without a product bug or harness bug filed to the owner."
},
{
"id": "startup-upgrade.persisted-session-corpus",
"title": "Current Orca preserves or recovers old production persisted sessions",
@@ -1182,7 +1430,7 @@
"macos"
],
"coveredProviders": [],
"coverageNotes": "Local macOS evidence on main@1282f5c2d; identical files to the pending stack. Real OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.",
"coverageNotes": "Local macOS evidence on main@1282f5c2d, deterministic renderer-unit coverage for the Linux/Sogou candidate-key policy, and a local Electron/CDP live-PTY Sogou-style repro. Real Linux/Sogou OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/6699",
"https://github.com/stablyai/orca/pull/6682",
@@ -1190,14 +1438,20 @@
"https://github.com/stablyai/orca/pull/6999"
],
"invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY.",
"oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, and paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures. Real IME paths and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.",
"oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary post-IME typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit candidate selectors submit only the committed Chinese text. Real IME paths and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts"
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
"pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts"
],
"testFiles": [
"src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts",
"src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts"
"src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts",
"src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts",
"src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
"tests/e2e/chinese-ime-chat-input-repro.spec.ts"
],
"assertionRefs": [
{
@@ -1218,34 +1472,80 @@
"assertions": [
"paste/runtime forwarding avoids duplicate terminal payloads"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts",
"assertions": [
"empty Sogou-style compositionupdate keeps composition active",
"stale composition state expires so editing keys cannot stay suppressed indefinitely",
"post-composition candidate guard is armed only by Sogou-style empty updates and clears after real typing"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts",
"assertions": [
"suppressed candidate keydown arms only the matching keypress/keyup release guard",
"held-key repeat keydowns stay guarded until their keyup, even past expiry",
"fresh keydowns, other keys, modified keys, and expired keypresses are not guarded, and a fresh press drops a stale pending release"
]
},
{
"file": "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts",
"assertions": [
"standalone Linux 229 keydowns reach xterm while Windows 229 keydowns stay suppressed",
"candidate Space/digit selectors are suppressed only while candidate guards are active"
]
},
{
"file": "src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
"assertions": [
"macOS standalone Process key behavior and composition-owned key suppression stay intact"
]
},
{
"file": "tests/e2e/chinese-ime-chat-input-repro.spec.ts",
"assertions": [
"Sogou-style Space candidate selection submits only the committed Chinese character",
"Sogou-style digit candidate selection submits only the committed Chinese phrase",
"Post-composition Sogou-style digit selection stays out of the PTY after compositionend"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-03",
"date": "2026-07-07",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
"result": "passed",
"durationSeconds": 0.9,
"summary": "3 test file(s) passed, 63 tests passed on main@1282f5c2d in a clean checkout."
"durationSeconds": 0.7,
"summary": "7 test file(s) passed, 144 tests passed on the Linux/Sogou candidate-key branch (includes held-key repeat guard coverage)."
},
{
"date": "2026-07-07",
"runner": "local",
"platform": "macos",
"command": "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts",
"result": "passed",
"durationSeconds": 72.0,
"summary": "Electron/CDP IME repro passed: Sogou candidate selection, post-composition candidate selection, and existing Chinese IME harness passed; real Codex IME test was skipped behind ORCA_E2E_REAL_CODEX_IME."
}
],
"runtimeBudget": {
"p95Seconds": 30,
"scope": "renderer unit plus platform soak"
"p95Seconds": 90,
"scope": "renderer unit plus focused Electron/CDP IME repro"
},
"flakeHistory": {
"status": "unknown",
"evidence": "Focused renderer input tests are now registered; needs soak history and platform IME evidence before promotion."
"evidence": "Focused renderer input tests and the Electron/CDP Sogou-style repro are now registered; needs soak history and true platform IME evidence before promotion."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Focused tests cover existing native-text, input-source, and paste/runtime forwarding contracts. Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix."
"evidence": "Focused tests cover existing native-text, input-source, paste/runtime forwarding, Linux/Sogou candidate selector, post-composition guard contracts, and live-PTY Sogou-style candidate commits. Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix."
},
"performanceBudget": {
"required": false,
"evidence": "Required if input forwarding adds polling, repeated event listeners, or renderer hot-path work."
"required": true,
"evidence": "Terminal IME hot-path audit clean on 2026-07-07: candidate guard adds O(1) boolean checks plus a bounded Space/digit release map; composition listeners are disposed with pane lifecycle; no polling, IPC fan-out, subprocess work, or SSH/remote transport cost added."
},
"promotionCriteria": [
"Cover deterministic byte/cell oracles first.",
@@ -1253,7 +1553,7 @@
"Pair with Windows ConPTY keyboard reset for Windows standard-key behavior."
],
"knownGaps": [
"Current command is a renderer-unit slice; real OS IME automation may need manual or soak evidence.",
"Current commands include renderer-unit coverage and a CDP-driven Electron repro; real OS IME automation may need manual or soak evidence.",
"Backspace/Enter during composition, JIS yen, Arabic/RTL, paste edge cases, and Windows ConPTY post-agent key reset still need representative gate coverage."
],
"demotionRule": "Cannot promote if success is based only on DOM text without PTY byte/cell evidence."
@@ -1320,6 +1620,144 @@
],
"demotionRule": "Cannot promote if standard-key behavior is inferred without PTY byte evidence."
},
{
"id": "terminal-input.agent-prompt-injection",
"title": "Orchestration agent prompts arrive as bracketed paste before submit",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-input",
"layer": "runtime-contract-and-cli-repro",
"surfaces": [
"terminal input",
"agent prompt injection",
"orchestration dispatch",
"PTY writes",
"bracketed paste"
],
"platforms": [
"macos",
"linux",
"windows"
],
"providers": [
"local",
"daemon",
"ssh",
"remote-runtime"
],
"coveredPlatforms": [
"macos"
],
"coveredProviders": [
"local"
],
"coverageNotes": "Local macOS evidence covers the runtime PTY write contract and a live dev-runtime CLI repro. SSH, daemon, remote-runtime, Linux, and Windows remain provider/platform gaps; the product path stays provider-owned and does not add local filesystem or process assumptions.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/7226"
],
"invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and send Enter only after the paste frame completes.",
"oracle": "Runtime tests assert the exact PTY write sequence and failure cleanup; orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness dispatches a 32KB task to a fake Codex-like TUI and requires marker present, bracketed paste present, zero unframed line breaks, and submit observed.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/runtime/orchestration/coordinator.test.ts",
"node tools/repro-orchestration-long-prompt.mjs --cli out/bin/orca-dev --mode codex-like --size-kb 32 --timeout-ms 20000"
],
"testFiles": [
"src/shared/agent-prompt-injection.test.ts",
"src/main/runtime/orca-runtime.test.ts",
"src/main/runtime/rpc/methods/orchestration.test.ts",
"src/main/runtime/orchestration/coordinator.test.ts",
"tools/repro-orchestration-long-prompt.mjs"
],
"assertionRefs": [
{
"file": "src/shared/agent-prompt-injection.test.ts",
"assertions": [
"agent prompts are always framed as bracketed paste",
"submit stays separate from the paste frame",
"embedded ESC bytes are made inert before framing",
"chunk reconstruction preserves the paste frame"
]
},
{
"file": "src/main/runtime/orca-runtime.test.ts",
"assertions": [
"runtime writes bracketed paste before a delayed submit",
"large prompt frames are chunked and reconstructed before submit",
"partial prompt write failure closes the paste frame and does not submit"
]
},
{
"file": "src/main/runtime/rpc/methods/orchestration.test.ts",
"assertions": [
"orchestration.dispatch uses the agent prompt path for injected preambles",
"raw terminal.send is not called for injected task prompts",
"failed prompt injection rolls back the active dispatch"
]
},
{
"file": "src/main/runtime/orchestration/coordinator.test.ts",
"assertions": [
"coordinator dispatch failures from prompt injection circuit-break through the DB",
"silent-skip paths do not attempt prompt injection"
]
},
{
"file": "tools/repro-orchestration-long-prompt.mjs",
"assertions": [
"fake Codex-like worker observes submit after long orchestration dispatch",
"32KB task marker survives before submit",
"prompt bytes include a bracketed-paste frame with zero unframed line breaks"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-07",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/runtime/orchestration/coordinator.test.ts",
"result": "passed",
"durationSeconds": 7.4,
"summary": "4 test files passed, 697 tests passed; covers framing, runtime PTY writes, orchestration RPC dispatch, and coordinator dispatch behavior."
},
{
"date": "2026-07-07",
"runner": "local",
"platform": "macos",
"command": "node tools/repro-orchestration-long-prompt.mjs --cli out/bin/orca-dev --mode codex-like --size-kb 32 --timeout-ms 20000",
"result": "passed",
"durationSeconds": 2.2,
"summary": "Live dev-runtime repro passed: expectedSpecBytes=32830, hasSubmit=true, rawContainsMarker=true, hasBracketedPasteFrame=true, unframedLineBreaks=0, contractOk=true."
}
],
"runtimeBudget": {
"p95Seconds": 30,
"scope": "runtime contract tests plus optional local dev-runtime CLI repro"
},
"flakeHistory": {
"status": "unknown",
"evidence": "First local macOS evidence only; needs repeated dev-runtime harness runs and provider matrix evidence before promotion."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "The live harness reproduced the unsafe raw multiline contract before the fix and passes after the fix; intentional-break evidence is local only and not yet in CI."
},
"performanceBudget": {
"required": true,
"evidence": "Agent prompt dispatch remains O(prompt bytes), uses the existing 16KB terminal input chunking and one existing 500ms submit delay, and adds no polling, provider listing, subprocess churn, hidden-pane wakeups, or renderer work."
},
"promotionCriteria": [
"Run the live harness in soak with a self-starting dev runtime or provider-contract fixture.",
"Add daemon, SSH, remote-runtime, Linux, and Windows evidence or mark narrower provider scope.",
"Capture stable red/green intentional-break evidence in CI before blocking promotion."
],
"knownGaps": [
"Live harness command currently expects an already-running dev runtime and generated out/bin/orca-dev wrapper.",
"No Windows ConPTY, Linux PTY, SSH, daemon, or remote-runtime live evidence yet.",
"Push-on-idle orchestration message banners remain outside this dispatch-prompt gate."
],
"demotionRule": "Demote or quarantine if the live harness flakes without a product bug or harness bug filed to the terminal-input owner."
},
{
"id": "terminal-render.windows-cjk-repaint",
"title": "Windows ConPTY wide glyphs and cursor rewrites repaint without stale cells",
+5 -1
View File
@@ -8,6 +8,8 @@ if (process.platform !== 'darwin') {
}
runPnpmScript('build:computer-macos')
runPnpmScript('build:notification-status-macos')
process.exit(0)
function runPnpmScript(scriptName) {
const npmExecPath = process.env.npm_execpath
@@ -22,5 +24,7 @@ function runPnpmScript(scriptName) {
if (result.signal) {
process.kill(process.pid, result.signal)
}
process.exit(result.status ?? (result.error ? 1 : 0))
if (result.status !== 0 || result.error) {
process.exit(result.status ?? 1)
}
}
@@ -0,0 +1,97 @@
#!/usr/bin/env node
// Builds the orca-notification-status helper binary.
//
// The helper reads UNUserNotificationCenter settings for the app it ships
// inside (see native/notification-status-macos/main.swift). The target
// CFBundleIdentifier is embedded as a __TEXT,__info_plist section so every
// later `codesign --force` pass (electron-builder's signing, the dev runner's
// ad-hoc deep sign) derives the correct code identifier automatically —
// macOS keys notification records to that identifier.
import { execFileSync } from 'node:child_process'
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
const repoRoot = path.resolve(import.meta.dirname, '../..')
const sourcePath = path.join(repoRoot, 'native', 'notification-status-macos', 'main.swift')
const defaultOutputPath = path.join(
repoRoot,
'native',
'notification-status-macos',
'.build',
'release',
'orca-notification-status'
)
if (process.platform !== 'darwin') {
process.exit(0)
}
const args = process.argv.slice(2)
const bundleId = readArg('--bundle-id') ?? 'com.stablyai.orca'
const outputPath = readArg('--output') ?? defaultOutputPath
// Why: dev launches only need the host architecture; release builds ship a
// universal binary matching the app's x64 + arm64 targets.
const singleArch = args.includes('--single-arch')
const workDir = path.join(tmpdir(), `orca-notification-status-${process.pid}`)
mkdirSync(workDir, { recursive: true })
try {
const plistPath = path.join(workDir, 'Info.plist')
writeFileSync(plistPath, embeddedInfoPlist(bundleId), 'utf8')
const triples = singleArch
? [process.arch === 'arm64' ? 'arm64-apple-macosx' : 'x86_64-apple-macosx']
: ['arm64-apple-macosx', 'x86_64-apple-macosx']
const builtBinaries = triples.map((triple) => {
const output = path.join(workDir, `orca-notification-status-${triple}`)
execFileSync(
'swiftc',
[
'-O',
sourcePath,
'-target',
triple.replace('-apple-macosx', '-apple-macosx11.0'),
'-o',
output,
'-Xlinker',
'-sectcreate',
'-Xlinker',
'__TEXT',
'-Xlinker',
'__info_plist',
'-Xlinker',
plistPath
],
{ stdio: 'inherit' }
)
return output
})
mkdirSync(path.dirname(outputPath), { recursive: true })
if (builtBinaries.length === 1) {
execFileSync('cp', [builtBinaries[0], outputPath])
} else {
execFileSync('lipo', ['-create', ...builtBinaries, '-output', outputPath])
}
execFileSync('chmod', ['755', outputPath])
} finally {
rmSync(workDir, { recursive: true, force: true })
}
function readArg(name) {
const index = args.indexOf(name)
return index >= 0 ? args[index + 1] : undefined
}
function embeddedInfoPlist(identifier) {
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>${identifier}</string>
<key>CFBundleName</key>
<string>orca-notification-status</string>
</dict>
</plist>
`
}
+254
View File
@@ -0,0 +1,254 @@
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
// Ratchet gate for the oxlint `max-lines` rule.
//
// oxlint already fails any file that exceeds max-lines WITHOUT a suppression, so
// the only way a file grows past the budget is by adding an `eslint/oxlint-disable
// max-lines` comment or a per-file `max-lines` bump in mobile/.oxlintrc.json. This
// check freezes the set of files currently allowed to do that (the baseline) and
// fails CI when a NEW bypass appears — the existing over-limit files are
// grandfathered; new ones must split instead. The baseline may only shrink.
const BASELINE_PATH = 'config/max-lines-baseline.txt'
const MOBILE_CONFIG_PATH = 'mobile/.oxlintrc.json'
// These two files legitimately contain the directive text as data (regex, fixtures),
// so scanning them would self-flag. The ratchet does not police itself.
const SELF_FILES = new Set([
'config/scripts/check-max-lines-ratchet.mjs',
'config/scripts/check-max-lines-ratchet.test.mjs'
])
// Default max-lines budgets from .oxlintrc.json (counted lines).
export function defaultLimitForPath(p) {
if (/\.(test|spec)\.(ts|tsx)$/.test(p)) {
return 800
}
if (p.endsWith('.tsx')) {
return 400
}
if (p.endsWith('.mjs')) {
return 600
}
return 300
}
// True if the source contains an eslint/oxlint disable directive listing `max-lines`
// (block or line, bare or compound, with or without a `-- Why:` reason).
export function hasMaxLinesDisable(sourceText) {
const re = /(?:eslint|oxlint)-disable(?:-next-line|-line)?\b([^\n]*)/g
let m
while ((m = re.exec(sourceText)) !== null) {
let rules = m[1]
rules = rules.split('--')[0] // strip the reason
const close = rules.indexOf('*/')
if (close !== -1) {
rules = rules.slice(0, close) // strip block-comment tail
}
if (/\bmax-lines\b/.test(rules)) {
return true
}
}
return false
}
// Per-file `max-lines` bumps in mobile/.oxlintrc.json whose `max` exceeds the
// default for that glob (a lower `max` is stricter, not a bypass).
export function collectMobileBumps(configText) {
const cfg = JSON.parse(configText)
const bumps = []
for (const override of cfg.overrides ?? []) {
const rule = override.rules?.['max-lines']
if (!Array.isArray(rule) || typeof rule[1]?.max !== 'number') {
continue
}
for (const glob of override.files ?? []) {
if (rule[1].max > defaultLimitForPath(glob)) {
bumps.push(`mobile-config ${glob}`)
}
}
}
return bumps
}
export function parseBaseline(text) {
return new Set(
text
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('#'))
)
}
export function diffBaseline(current, baseline) {
const cur = new Set(current)
const base = baseline instanceof Set ? baseline : new Set(baseline)
const added = [...cur].filter((e) => !base.has(e)).sort()
const stale = [...base].filter((e) => !cur.has(e)).sort()
return { added, stale }
}
// Collect every current suppression entry from the tracked tree.
export function collectCurrentSuppressions(root = process.cwd()) {
const tracked = execFileSync('git', ['ls-files', '*.ts', '*.tsx', '*.mjs'], {
cwd: root,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024
})
.split('\n')
.filter(Boolean)
.filter((f) => !SELF_FILES.has(f))
const entries = []
for (const rel of tracked) {
let src
try {
src = fs.readFileSync(path.join(root, rel), 'utf8')
} catch {
continue
}
if (hasMaxLinesDisable(src)) {
entries.push(`inline ${rel}`)
}
}
const mobileCfgPath = path.join(root, MOBILE_CONFIG_PATH)
if (fs.existsSync(mobileCfgPath)) {
entries.push(...collectMobileBumps(fs.readFileSync(mobileCfgPath, 'utf8')))
}
return entries.sort()
}
function printAddedFailure(added) {
for (const entry of added) {
console.error(`::error::New max-lines bypass not allowed: ${entry}`)
}
console.error('')
console.error('╭────────────────────────────────────────────────────────────────────────────╮')
console.error('│ ❌ max-lines ratchet failed — a NEW file is trying to exceed the line cap. │')
console.error('╰────────────────────────────────────────────────────────────────────────────╯')
console.error('')
console.error(` ${added.length} file(s)/glob(s) newly bypass the oxlint \`max-lines\` rule:`)
console.error('')
for (const entry of added) {
const [kind, ...rest] = entry.split(' ')
const target = rest.join(' ')
const how =
kind === 'inline'
? 'added an eslint/oxlint-disable max-lines comment'
: 'added a per-file max-lines bump in mobile/.oxlintrc.json'
console.error(` • ${target}\n ↳ ${how}`)
}
console.error('')
console.error(' Orca caps file size (300 .ts / 400 .tsx / 600 .mjs / 800 test — non-blank,')
console.error(
' non-comment lines). Existing oversized files are grandfathered; NEW ones are not.'
)
console.error('')
console.error(' ✅ Fix it: SPLIT the file into focused modules — do NOT suppress the rule.')
console.error(' See AGENTS.md → "Lint Rules: Do Not Disable Max Lines".')
console.error('')
console.error(' (If you are intentionally, with reviewer sign-off, adding an unavoidable')
console.error(` exception, add the exact line(s) above to ${BASELINE_PATH}.)`)
console.error('')
}
function printStaleFailure(stale) {
for (const entry of stale) {
console.error(`::error::Stale max-lines baseline entry (prune it): ${entry}`)
}
console.error('')
console.error('╭────────────────────────────────────────────────────────────────────────────╮')
console.error('│ ⚠️ max-lines baseline is out of date — nice work removing a bypass! │')
console.error('╰────────────────────────────────────────────────────────────────────────────╯')
console.error('')
console.error(` ${stale.length} baseline entr(y/ies) no longer have a max-lines suppression.`)
console.error(
' The baseline may only shrink, so these must be removed to keep re-adding blocked:'
)
console.error('')
for (const entry of stale) {
console.error(` • ${entry}`)
}
console.error('')
console.error(` ✅ Fix it (one command): pnpm check:max-lines-ratchet --prune`)
console.error('')
}
export function main(root = process.cwd()) {
const baselineFile = path.join(root, BASELINE_PATH)
if (!fs.existsSync(baselineFile)) {
console.error(
`::error::Missing ${BASELINE_PATH}. Generate it with: node config/scripts/check-max-lines-ratchet.mjs --init`
)
return 1
}
const baseline = parseBaseline(fs.readFileSync(baselineFile, 'utf8'))
const current = collectCurrentSuppressions(root)
const { added, stale } = diffBaseline(current, baseline)
if (added.length > 0) {
printAddedFailure(added)
if (stale.length > 0) {
console.error(
` (Also: ${stale.length} stale baseline entr(y/ies) can be pruned — see below.)`
)
printStaleFailure(stale)
}
return 1
}
if (stale.length > 0) {
printStaleFailure(stale)
return 1
}
console.log(
`max-lines ratchet OK — ${current.length} grandfathered suppression(s), no new bypasses.`
)
return 0
}
function writeBaseline(root, entries) {
const header = [
'# Files/globs currently allowed to exceed the oxlint `max-lines` budget.',
'# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —',
'# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").',
'# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)',
''
].join('\n')
fs.writeFileSync(path.join(root, BASELINE_PATH), `${header}${entries.join('\n')}\n`)
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const root = process.cwd()
const arg = process.argv[2]
if (arg === '--init') {
// One-time bootstrap: capture the current suppression set as the baseline.
const entries = collectCurrentSuppressions(root)
writeBaseline(root, entries)
console.log(`Wrote ${BASELINE_PATH} with ${entries.length} entries.`)
process.exit(0)
}
if (arg === '--prune') {
// Remove baseline entries whose suppression is gone (shrink only; never adds).
const current = new Set(collectCurrentSuppressions(root))
const baseline = parseBaseline(fs.readFileSync(path.join(root, BASELINE_PATH), 'utf8'))
const kept = [...baseline].filter((e) => current.has(e)).sort()
const newlyAdded = [...current].filter((e) => !baseline.has(e))
writeBaseline(root, kept)
console.log(
`Pruned baseline to ${kept.length} entries (removed ${baseline.size - kept.length}).`
)
if (newlyAdded.length > 0) {
console.error(
`::error::--prune does not add entries; ${newlyAdded.length} new bypass(es) remain — split those files.`
)
process.exit(1)
}
process.exit(0)
}
process.exit(main(root))
}
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import {
collectMobileBumps,
defaultLimitForPath,
diffBaseline,
hasMaxLinesDisable,
parseBaseline
} from './check-max-lines-ratchet.mjs'
describe('hasMaxLinesDisable', () => {
it('detects a bare block disable', () => {
expect(hasMaxLinesDisable('/* eslint-disable max-lines */\nexport const a = 1\n')).toBe(true)
})
it('detects the oxlint spelling', () => {
expect(hasMaxLinesDisable('/* oxlint-disable max-lines */\n')).toBe(true)
})
it('detects a disable with a -- Why reason', () => {
expect(hasMaxLinesDisable('/* eslint-disable max-lines -- Why: one owner. */\n')).toBe(true)
})
it('detects a multi-line block where the reason wraps', () => {
const src =
'/* eslint-disable max-lines -- Why: this contract is\n * intentionally centralized. */\nimport x from "y"\n'
expect(hasMaxLinesDisable(src)).toBe(true)
})
it('detects max-lines inside a compound rule list', () => {
expect(hasMaxLinesDisable('/* eslint-disable no-control-regex, max-lines -- Why: x */\n')).toBe(
true
)
expect(hasMaxLinesDisable('/* eslint-disable max-lines, no-control-regex */\n')).toBe(true)
})
it('detects a line-scoped disable', () => {
expect(hasMaxLinesDisable('const a = 1 // eslint-disable-line max-lines\n')).toBe(true)
})
it('ignores a disable for an unrelated rule', () => {
expect(hasMaxLinesDisable('/* eslint-disable no-console */\n')).toBe(false)
})
it('does not treat "max-lines" appearing only in the reason text as a suppression', () => {
// max-lines is after the `--`, so it is prose, not a suppressed rule.
expect(
hasMaxLinesDisable('/* eslint-disable no-console -- we could hit max-lines later */\n')
).toBe(false)
})
it('returns false for ordinary source', () => {
expect(hasMaxLinesDisable('export function f() {\n return 42\n}\n')).toBe(false)
})
})
describe('defaultLimitForPath', () => {
it('uses 800 for tests, 400 for tsx, 600 for mjs, 300 otherwise', () => {
expect(defaultLimitForPath('a/b.test.ts')).toBe(800)
expect(defaultLimitForPath('a/b.spec.tsx')).toBe(800)
expect(defaultLimitForPath('a/b.tsx')).toBe(400)
expect(defaultLimitForPath('a/b.mjs')).toBe(600)
expect(defaultLimitForPath('a/b.ts')).toBe(300)
})
})
describe('collectMobileBumps', () => {
it('captures only overrides whose max exceeds the default for the glob', () => {
const cfg = JSON.stringify({
overrides: [
{ files: ['app/h/*/tasks.tsx'], rules: { 'max-lines': ['error', { max: 14682 }] } }, // bump (>400)
{
files: ['src/terminal/TerminalWebView.tsx'],
rules: { 'max-lines': ['error', { max: 379 }] }
}, // stricter (<400), skip
{ files: ['scripts/mock-server.ts'], rules: { 'max-lines': ['error', { max: 407 }] } } // bump (>300)
]
})
expect(collectMobileBumps(cfg)).toEqual([
'mobile-config app/h/*/tasks.tsx',
'mobile-config scripts/mock-server.ts'
])
})
it('ignores overrides without a max-lines rule', () => {
const cfg = JSON.stringify({
overrides: [{ files: ['a.tsx'], rules: { 'no-console': 'off' } }]
})
expect(collectMobileBumps(cfg)).toEqual([])
})
})
describe('parseBaseline', () => {
it('drops comments and blank lines', () => {
const b = parseBaseline('# header\n\ninline a.ts\nmobile-config x/*.tsx\n')
expect(b).toEqual(new Set(['inline a.ts', 'mobile-config x/*.tsx']))
})
})
describe('diffBaseline', () => {
it('reports added and stale entries', () => {
const { added, stale } = diffBaseline(
['inline b.ts', 'inline c.ts'],
new Set(['inline a.ts', 'inline b.ts'])
)
expect(added).toEqual(['inline c.ts']) // new bypass
expect(stale).toEqual(['inline a.ts']) // suppression removed
})
it('is clean when current matches baseline', () => {
const { added, stale } = diffBaseline(['inline a.ts'], new Set(['inline a.ts']))
expect(added).toEqual([])
expect(stale).toEqual([])
})
})
@@ -110,6 +110,39 @@ describe('computer-use e2e workflow', () => {
}
})
it('boots the built daemon under plain Node in the PR native-smoke job after the main build', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
const steps = workflow.jobs['native-smoke'].steps
const runs = steps.map((step) => step.run).filter((run) => typeof run === 'string')
const buildIndex = runs.indexOf('pnpm build:electron-vite')
const daemonSmokeIndex = runs.indexOf('node config/scripts/daemon-boot-smoke.mjs')
expect(daemonSmokeIndex, 'native-smoke must boot the built daemon').toBeGreaterThanOrEqual(0)
expect(
buildIndex,
'daemon boot smoke must run after the main bundle is built'
).toBeGreaterThanOrEqual(0)
expect(daemonSmokeIndex).toBeGreaterThan(buildIndex)
})
it('re-runs the native-smoke job when the daemon bundle graph changes', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
const triggerPaths = workflow.on.pull_request.paths
expect(triggerPaths).toEqual(
expect.arrayContaining([
'config/scripts/daemon-boot-smoke.mjs',
'electron.vite.config.ts',
'build-plugins/**',
'src/main/daemon/**'
])
)
})
it('runs Linux computer-use e2e in the PR native-smoke job under Xvfb', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
+217
View File
@@ -0,0 +1,217 @@
/**
* Boots the BUILT terminal daemon (out/main/daemon-entry.js) under plain Node —
* the exact way production forks it (ELECTRON_RUN_AS_NODE = a plain-Node
* process) — and asserts it starts, serves a real PTY, and stops.
*
* Why this exists: native-smoke CI (and packaging) went green while
* v1.4.129-rc.1 shipped a daemon that exited code 1 at module load because an
* electron `require` leaked into its bundle graph. Nothing executed the built
* entry under plain Node, so the outage was invisible until an adopted old
* daemon died in the field. This runs on every PR that touches the daemon.
*
* Hard assertions (fail the job):
* - the daemon signals `{ type: 'ready' }` over IPC within the timeout, and
* - it terminates when asked (no hang / zombie).
* Best-effort (logged skip, never fails): an end-to-end `ptySpawnHealth` RPC,
* because node-pty spawn can be flaky on constrained CI runners.
*/
import { fork } from 'node:child_process'
import { connect } from 'node:net'
import { randomUUID } from 'node:crypto'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
const projectDir = resolve(import.meta.dirname, '../..')
const entryPath = join(projectDir, 'out', 'main', 'daemon-entry.js')
const READY_TIMEOUT_MS = 30_000
const PTY_HEALTH_TIMEOUT_MS = 10_000
const SHUTDOWN_TIMEOUT_MS = 10_000
function log(message) {
process.stdout.write(`[daemon-boot-smoke] ${message}\n`)
}
// Why: the daemon rejects a hello whose protocol version differs, so read the
// current version from source rather than hardcoding a number that can drift.
function readProtocolVersion() {
const source = readFileSync(join(projectDir, 'src/main/daemon/types.ts'), 'utf8')
const match = source.match(/PROTOCOL_VERSION\s*=\s*(\d+)/)
if (!match) {
throw new Error('could not read PROTOCOL_VERSION from src/main/daemon/types.ts')
}
return Number(match[1])
}
function makeSocketPath(userDataDir) {
// Why: Windows AF_UNIX-style IPC uses named pipes; POSIX uses a filesystem
// socket kept under the scratch userData dir so cleanup removes it.
if (process.platform === 'win32') {
return `\\\\.\\pipe\\orca-daemon-smoke-${process.pid}-${randomUUID()}`
}
return join(userDataDir, 'daemon.sock')
}
// Best-effort end-to-end PTY check over the daemon's own control-socket RPC.
// Connects a single control socket, completes the hello handshake, and calls
// `ptySpawnHealth` (the daemon spawns a throwaway PTY internally). Resolves
// true on success, false on any failure — never throws.
function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
return new Promise((resolveCheck) => {
let settled = false
let buffer = ''
const socket = connect(socketPath)
const finish = (ok, reason) => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
socket.destroy()
if (!ok && reason) {
log(`PTY spawn health check skipped (best-effort): ${reason}`)
}
resolveCheck(ok)
}
const timer = setTimeout(() => finish(false, 'timed out'), PTY_HEALTH_TIMEOUT_MS)
socket.on('error', (err) => finish(false, err.message))
socket.on('connect', () => {
const token = readFileSync(tokenPath, 'utf8').trim()
socket.write(
`${JSON.stringify({
type: 'hello',
version: protocolVersion,
token,
clientId: randomUUID(),
role: 'control'
})}\n`
)
})
socket.on('data', (chunk) => {
buffer += chunk.toString('utf8')
let newlineIdx = buffer.indexOf('\n')
while (newlineIdx !== -1) {
const line = buffer.slice(0, newlineIdx)
buffer = buffer.slice(newlineIdx + 1)
let msg
try {
msg = JSON.parse(line)
} catch {
finish(false, 'invalid response line')
return
}
if (msg.type === 'hello') {
if (!msg.ok) {
finish(false, `hello rejected: ${msg.error ?? 'unknown'}`)
return
}
socket.write(`${JSON.stringify({ id: 'health-1', type: 'ptySpawnHealth' })}\n`)
} else if (msg.id === 'health-1') {
finish(
msg.ok === true,
msg.ok === true ? undefined : (msg.error ?? 'ptySpawnHealth failed')
)
return
}
newlineIdx = buffer.indexOf('\n')
}
})
})
}
async function main() {
const userDataDir = mkdtempSync(join(tmpdir(), 'orca-daemon-boot-smoke-'))
const socketPath = makeSocketPath(userDataDir)
const tokenPath = join(userDataDir, 'daemon.token')
const protocolVersion = readProtocolVersion()
log(`forking ${entryPath} under plain Node (${process.execPath})`)
const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], {
// Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in
// CI, and this is exactly the runtime where a leaked `require("electron")`
// throws MODULE_NOT_FOUND — the failure this smoke exists to catch.
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir }
})
let stderr = ''
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString('utf8')
})
child.stdout?.on('data', (chunk) => {
process.stdout.write(chunk)
})
const cleanup = () => {
if (child.exitCode === null && child.signalCode === null && child.pid) {
try {
child.kill('SIGKILL')
} catch {
// already gone
}
}
rmSync(userDataDir, { recursive: true, force: true })
}
try {
await new Promise((resolveReady, rejectReady) => {
const timer = setTimeout(() => {
rejectReady(
new Error(
`daemon did not signal 'ready' within ${READY_TIMEOUT_MS}ms.\nstderr:\n${stderr}`
)
)
}, READY_TIMEOUT_MS)
child.on('message', (msg) => {
if (msg && typeof msg === 'object' && msg.type === 'ready') {
clearTimeout(timer)
resolveReady()
}
})
child.on('error', (err) => {
clearTimeout(timer)
rejectReady(new Error(`daemon fork errored: ${err.message}\nstderr:\n${stderr}`))
})
child.on('exit', (code, signal) => {
clearTimeout(timer)
rejectReady(
new Error(
`daemon exited before 'ready' (code=${code}, signal=${signal}).\nstderr:\n${stderr}`
)
)
})
})
log('daemon signaled ready')
const ptyHealthy = await runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion)
if (ptyHealthy) {
log('ptySpawnHealth OK — daemon spawned a real PTY end-to-end')
}
await new Promise((resolveExit, rejectExit) => {
const timer = setTimeout(() => {
rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`))
}, SHUTDOWN_TIMEOUT_MS)
child.on('exit', (code, signal) => {
clearTimeout(timer)
log(`daemon exited after signal (code=${code}, signal=${signal})`)
resolveExit()
})
// Why: SIGTERM is the graceful stop on POSIX (the daemon handles it);
// Windows has no POSIX signal delivery, so Node maps this to process
// termination. Either way the hard assertion is "it stops, no hang".
child.kill('SIGTERM')
})
log('PASS: daemon booted, served, and shut down under plain Node')
} finally {
cleanup()
}
}
main().catch((error) => {
process.stderr.write(`[daemon-boot-smoke] FAIL: ${error.message}\n`)
process.exitCode = 1
})
@@ -63,12 +63,36 @@ describe('electron-builder config', () => {
)
})
// Why: on macOS 26 UNUserNotificationCenter aborts for executables launched
// from Contents/Resources, so the helper must ship in Contents/MacOS (#7929).
it('ships the mac notification-status helper in Contents/MacOS, not Resources', () => {
expect(electronBuilderConfig.mac.extraFiles).toEqual(
expect.arrayContaining([
expect.objectContaining({
from: 'native/notification-status-macos/.build/release/orca-notification-status',
to: 'MacOS/orca-notification-status'
})
])
)
expect(electronBuilderConfig.mac.extraResources).not.toEqual(
expect.arrayContaining([expect.objectContaining({ to: 'orca-notification-status' })])
)
})
it('unpacks the compiled CommonJS boundary with CLI runtime files', () => {
expect(electronBuilderConfig.asarUnpack).toEqual(
expect.arrayContaining(['out/package.json', 'out/cli/**', 'out/shared/**'])
)
})
// Why: without the unpacked entry the watcher client silently falls back to
// in-process @parcel/watcher, reintroducing the #7547 main-process crash.
it('unpacks the forked parcel-watcher process entry', () => {
expect(electronBuilderConfig.asarUnpack).toEqual(
expect.arrayContaining(['out/main/parcel-watcher-process-entry.js'])
)
})
it('uses the multi-size icon source for Linux packages', () => {
expect(electronBuilderConfig.linux.icon).toBe('resources/build/icon.icns')
})
@@ -221,7 +221,7 @@ describe('Electron runtime package contract', () => {
expect(installRun).not.toMatch(/throw\s+\$_/)
})
it('temporarily allows publishing Windows after verifying the signed installer only', () => {
it('verifies Windows inner binary signatures fail-open before publishing', () => {
const releaseWorkflow = readFileSync(
join(projectDir, '.github/workflows/release-cut.yml'),
'utf8'
@@ -230,12 +230,38 @@ describe('Electron runtime package contract', () => {
const steps = parsedWorkflow.jobs.build.steps
const stepNames = steps.map((step) => step.name)
const outerVerifyIndex = stepNames.indexOf('Verify signed Windows installer')
const innerVerifyIndex = stepNames.indexOf('Verify signed Windows inner executable')
const innerVerifyIndex = stepNames.indexOf('Verify Windows inner binary signatures')
const evidenceIndex = stepNames.indexOf('Upload Windows inner signing evidence')
const publishIndex = stepNames.indexOf('Publish signed Windows release artifacts')
expect(outerVerifyIndex).toBeGreaterThan(-1)
expect(innerVerifyIndex).toBe(-1)
expect(publishIndex).toBe(outerVerifyIndex + 1)
expect(innerVerifyIndex).toBe(outerVerifyIndex + 1)
expect(evidenceIndex).toBe(innerVerifyIndex + 1)
expect(publishIndex).toBe(evidenceIndex + 1)
// Why fail-open: unsigned inner binaries must warn, not block, until the
// flow is proven on a real release (issue #7785). Flip this to 'true'
// together with the workflow env to make the gate required.
expect(steps[innerVerifyIndex].env.ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED).toBe('false')
// Why: every step in the inner-signing chain must be unable to fail the
// release — a SignPath outage or timeout falls through to today's
// unsigned-inner flow instead of blocking the cut.
const innerChainStepNames = [
'Stage unsigned inner PE files for signing',
'Upload unsigned inner binaries for SignPath',
'Submit inner binaries signing request',
'Notify Slack that inner-binary signing is waiting for approval',
'Download signed inner binaries from SignPath',
'Restore signed inner binaries into unpacked app',
'Replace cached elevate.exe with the signed copy',
'Rebuild NSIS installer from signed unpacked app'
]
for (const stepName of innerChainStepNames) {
const step = steps[stepNames.indexOf(stepName)]
expect(step, stepName).toBeDefined()
expect(step['continue-on-error'], stepName).toBe(true)
}
})
it('publishes both Linux release matrix entries', () => {
+54 -15
View File
@@ -97,16 +97,6 @@ function setPlistValue(plistPath, key, value) {
execFileSync('/usr/bin/plutil', ['-replace', key, '-string', value, plistPath])
}
function sanitizeBundleIdPart(value) {
return (
value
.toLowerCase()
.replace(/[^a-z0-9.-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'dev'
)
}
function sanitizeMacAppBundleName(value) {
return (
Array.from(value, (char) => {
@@ -138,7 +128,10 @@ function prepareMacDevElectronApp() {
const title = process.env.ORCA_DEV_DOCK_TITLE || 'Orca: dev'
const identityKey = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v4'
// v6: bundle the notification-status helper (real permission readout) and
// ad-hoc re-sign after plist edits so Notification Center accepts the
// bundle; bumping forces stale cached copies to be recreated.
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v6'
const hash = createHash('sha1')
.update(
`${sourceAppPath}\0${electronVersion ?? ''}\0${title}\0${identityKey}\0${bundleLayoutVersion}`
@@ -151,7 +144,17 @@ function prepareMacDevElectronApp() {
const appBundleName = `${sanitizeMacAppBundleName(title)}.app`
const appPath = path.join(distDir, appBundleName)
const markerPath = path.join(distDir, 'orca-dev-electron-app.json')
const bundleId = `com.stablyai.orca.dev.${sanitizeBundleIdPart(hash)}`
// Why: one stable id for every dev instance. Per-instance ids registered a
// new macOS Notification Settings entry for each branch × Electron version,
// piling up "Orca: <branch>" rows forever and breaking the notification
// settings deep-link (System Settings can't resolve an id it has no entry
// for and falls back to the root list). macOS keys notification permission
// by bundle id, so a single id also means granting notifications to one dev
// instance covers all of them. Trade-off: when two dev instances run at
// once, macOS may route a notification click to the other instance —
// Electron drops clicks for notification ids it didn't create, so the
// click is lost, not misdirected.
const bundleId = 'com.stablyai.orca.dev'
process.env.ORCA_DEV_MACOS_BUNDLE_ID = bundleId
const expectedMarker = JSON.stringify(
{ title, appBundleName, bundleId, sourceAppPath, electronVersion, bundleLayoutVersion },
@@ -206,9 +209,45 @@ function prepareMacDevElectronApp() {
setPlistValue(plistPath, 'CFBundleDisplayName', title)
setPlistValue(plistPath, 'CFBundleIdentifier', bundleId)
// Why no re-sign: dev launches execute the copied Electron binary directly,
// and Electron's framework bundle is ambiguous to codesign when deep-signing
// an already-built distribution. Avoid blocking `pn dev` on local signing.
// Why: the notification-status helper reads the app's real macOS
// notification authorization (UNUserNotificationCenter has no Electron
// API). It must live inside the bundle and carry the dev bundle id as its
// embedded/code-sign identifier — macOS keys notification records to the
// signing identifier. Non-fatal: without swiftc the permission card falls
// back to delivery-probe heuristics.
try {
execFileSync(
process.execPath,
[
path.join(repoRoot, 'config', 'scripts', 'build-notification-status-macos.mjs'),
'--bundle-id',
bundleId,
'--single-arch',
'--output',
path.join(appPath, 'Contents', 'MacOS', 'orca-notification-status')
],
{ stdio: 'inherit' }
)
} catch (error) {
console.warn(
`[orca-dev] notification-status helper build failed (permission card falls back to probes): ${error?.message ?? error}`
)
}
// Why: the plist edits above (and the copy itself) break the bundle's
// ad-hoc seal, and macOS refuses Notification Center registration for
// invalidly-signed apps — every dev notification fails with UNErrorDomain
// error 1 and the app never appears in System Settings > Notifications.
// An ad-hoc re-sign restores delivery, the permission prompt, and the
// notification-settings deep link for dev builds. Non-fatal: a signing
// failure should not block `pnpm dev`.
try {
execFileSync('/usr/bin/codesign', ['--force', '--deep', '--sign', '-', appPath])
} catch (error) {
console.warn(
`[orca-dev] ad-hoc codesign failed (dev notifications will not deliver): ${error?.message ?? error}`
)
}
writeFileSync(markerPath, expectedMarker, 'utf8')
process.env.ELECTRON_EXEC_PATH = executablePath
}
+326
View File
@@ -0,0 +1,326 @@
#!/usr/bin/env node
import { spawn, spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { hostname, tmpdir } from 'node:os'
import path from 'node:path'
import { createInterface } from 'node:readline'
const scriptDir = import.meta.dirname
const repoRoot = path.resolve(scriptDir, '..', '..')
const orcaDevScript = path.join(scriptDir, 'orca-dev.mjs')
const ensureNativeRuntimeScript = path.join(scriptDir, 'ensure-native-runtime.mjs')
const fixedProfileDir = process.env.ORCA_HEADLESS_PAIRING_PROFILE_DIR
const parsed = parseArgs(process.argv.slice(2))
if (parsed.help) {
printHelp()
process.exit(0)
}
if (hasForwardedServeFlag(parsed.serveArgs, 'no-pairing')) {
console.error('serve-headless-fresh-profile-pairing: --no-pairing cannot print a pairing code.')
process.exit(2)
}
if (hasForwardedServeFlag(parsed.serveArgs, 'recipe-json')) {
console.error(
'serve-headless-fresh-profile-pairing: --recipe-json detaches the server and cannot use an ephemeral profile safely.'
)
process.exit(2)
}
const serveArgs = withDefaultPairingAddress(parsed.serveArgs)
ensureElectronRuntime()
const profileDir =
fixedProfileDir ?? mkdtempSync(path.join(tmpdir(), 'orca-headless-pairing-profile-'))
const ownsProfileDir = !fixedProfileDir
mkdirSync(profileDir, { recursive: true })
let cleanedUp = false
let child = null
let sawPairingUrl = false
let stopAttempts = 0
// Why: temp dev worktrees do not have a root-owned chrome-sandbox; this script
// is only for local headless testing, not packaged production.
const childEnv = {
...process.env,
ORCA_DEV_USER_DATA_PATH: profileDir,
...(process.platform === 'linux'
? { ELECTRON_DISABLE_SANDBOX: process.env.ELECTRON_DISABLE_SANDBOX ?? '1' }
: {})
}
console.error(`[headless-pairing] userData=${profileDir}`)
console.error(`[headless-pairing] starting: orca-dev serve --json${formatForwardedArgs(serveArgs)}`)
child = spawn(process.execPath, [orcaDevScript, 'serve', '--json', ...serveArgs], {
cwd: repoRoot,
detached: process.platform !== 'win32',
env: childEnv,
stdio: ['inherit', 'pipe', 'inherit']
})
const stdoutLines = createInterface({ input: child.stdout })
stdoutLines.on('line', (line) => {
if (!printReadyLine(line)) {
console.log(line)
}
})
child.once('error', (error) => {
console.error(`[headless-pairing] failed to start server: ${error.message}`)
cleanupProfile()
process.exitCode = 1
})
child.once('exit', (code, signal) => {
stdoutLines.close()
if (!sawPairingUrl && code !== 0) {
console.error('[headless-pairing] server exited before printing a pairing URL.')
}
cleanupProfile()
if (typeof code === 'number') {
process.exitCode = code
return
}
process.exitCode = signal ? 1 : 0
})
process.on('SIGINT', () => stopChild('SIGINT'))
process.on('SIGTERM', () => stopChild('SIGTERM'))
/**
* Parses wrapper flags and forwards everything else to `orca serve`.
*/
function parseArgs(args) {
const serveArgs = []
let keepProfile = false
let help = false
for (const arg of args) {
if (arg === '--keep') {
keepProfile = true
continue
}
if (arg === '-h' || arg === '--help') {
help = true
continue
}
serveArgs.push(arg)
}
return { help, keepProfile, serveArgs }
}
/**
* Prints script usage without touching the dev profile or starting the server.
*/
function printHelp() {
console.log(`Usage: node config/scripts/serve-headless-fresh-profile-pairing.mjs [--keep] [orca serve flags]
Starts orca-dev serve --json with a fresh isolated userData profile, ensures Electron's dev runtime is usable, and prints the pairing URL.
Wrapper flags:
--keep Keep the fresh profile after the server exits.
-h, --help Show this help.
Forwarded examples:
node config/scripts/serve-headless-fresh-profile-pairing.mjs --port 6768
node config/scripts/serve-headless-fresh-profile-pairing.mjs --pairing-address 100.64.1.20
node config/scripts/serve-headless-fresh-profile-pairing.mjs --mobile-pairing
Environment:
ORCA_HEADLESS_PAIRING_ADDRESS=<host|host:port|ws://...> Override the auto pairing address.
ORCA_HEADLESS_PAIRING_PROFILE_DIR=/path/to/profile Use a fixed profile directory.
`)
}
/**
* Adds a reachable pairing address unless the caller provided one explicitly.
*/
function withDefaultPairingAddress(args) {
if (hasPairingAddress(args)) {
return args
}
const address = resolveDefaultPairingAddress()
if (!address) {
return args
}
console.error(`[headless-pairing] pairingAddress=${address} (auto)`)
return [...args, '--pairing-address', address]
}
/**
* Checks both supported CLI forms for an explicit pairing address.
*/
function hasPairingAddress(args) {
return hasForwardedServeFlag(args, 'pairing-address')
}
/**
* Checks both exact and `--flag=value` forms for a forwarded serve flag.
*/
function hasForwardedServeFlag(args, name) {
const flag = `--${name}`
return args.some((arg) => arg === flag || arg.startsWith(`${flag}=`))
}
/**
* Prefers an override, then Tailscale, then the OS hostname over loopback.
*/
function resolveDefaultPairingAddress() {
const configured = process.env.ORCA_HEADLESS_PAIRING_ADDRESS?.trim()
if (configured) {
return configured
}
const tailscaleAddress = readTailscaleAddress()
if (tailscaleAddress) {
return tailscaleAddress
}
const host = hostname().trim()
return host && host !== 'localhost' ? host : null
}
/**
* Reads the first Tailscale IPv4 address when Tailscale is installed.
*/
function readTailscaleAddress() {
const result = spawnSync('tailscale', ['ip', '-4'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
})
if (result.error || result.status !== 0) {
return null
}
return (
result.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? null
)
}
/**
* Formats forwarded arguments for the startup log.
*/
function formatForwardedArgs(args) {
if (args.length === 0) {
return ''
}
return ` ${args.map(formatShellArg).join(' ')}`
}
/**
* Quotes a shell-ish argument for display only.
*/
function formatShellArg(value) {
if (/^[\w./:=@+-]+$/.test(value)) {
return value
}
return JSON.stringify(value)
}
/**
* Runs the same Electron runtime preflight used by dev/start package scripts.
*/
function ensureElectronRuntime() {
console.error('[headless-pairing] ensuring Electron runtime...')
const result = spawnSync(process.execPath, [ensureNativeRuntimeScript, '--runtime=electron'], {
cwd: repoRoot,
stdio: 'inherit'
})
if (result.error) {
console.error(`[headless-pairing] Electron runtime preflight failed: ${result.error.message}`)
process.exit(1)
}
if (result.status !== 0) {
process.exit(result.status ?? 1)
}
}
/**
* Rewrites the CLI JSON readiness line into the pairing URL testers need.
*/
function printReadyLine(line) {
let payload
try {
payload = JSON.parse(line)
} catch {
return false
}
if (!payload || payload.type !== 'orca_server_ready') {
return false
}
console.log(`Orca server ready: ${payload.endpoint ?? 'websocket unavailable'}`)
if (payload.pairing?.endpoint) {
console.log(`Pairing endpoint: ${payload.pairing.endpoint}`)
}
if (payload.pairing?.webClientUrl) {
console.log(`Web client URL: ${payload.pairing.webClientUrl}`)
}
if (payload.pairing?.url) {
sawPairingUrl = true
console.log(`Pairing URL: ${payload.pairing.url}`)
console.error('[headless-pairing] server is running; press Ctrl+C to stop.')
return true
}
console.log('Pairing URL: unavailable')
return true
}
/**
* Stops the foreground server; repeated signals force its process tree down.
*/
function stopChild(signal) {
if (!child || child.killed) {
return
}
stopAttempts += 1
const targetSignal = stopAttempts > 1 ? 'SIGKILL' : signal
if (process.platform === 'win32' && child.pid) {
// Why: child.kill() only targets orca-dev on Windows; taskkill walks the
// CLI/Electron descendants so the fresh profile is not left locked.
const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
stdio: 'ignore',
windowsHide: true
})
killer.on('error', () => child?.kill(targetSignal))
return
}
if (process.platform !== 'win32' && child.pid) {
// Why: orca-dev synchronously owns the CLI child, which owns Electron; kill
// the spawned process group so programmatic shutdown does not orphan serve.
try {
process.kill(-child.pid, targetSignal)
return
} catch {
// Fall back to the direct child below if the process group already exited.
}
}
child.kill(targetSignal)
}
/**
* Removes only the ephemeral profile directory this script created.
*/
function cleanupProfile() {
if (cleanedUp) {
return
}
cleanedUp = true
if (parsed.keepProfile || !ownsProfileDir) {
console.error(`[headless-pairing] kept ${profileDir}`)
return
}
if (!existsSync(profileDir) || !profileDir.includes('orca-headless-pairing-profile-')) {
console.error(`[headless-pairing] skipped cleanup for unexpected profile path: ${profileDir}`)
return
}
try {
rmSync(profileDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
console.error(`[headless-pairing] removed ${profileDir}`)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`[headless-pairing] skipped cleanup for ${profileDir}: ${message}`)
}
}
@@ -0,0 +1,46 @@
const { existsSync } = require('node:fs')
const { spawnSync } = require('node:child_process')
const { join } = require('node:path')
// Why: v1.4.129-rc.1 shipped a terminal daemon that could not load (an electron
// `require` leaked into its bundle) while every build check passed. This boots
// the PACKAGED daemon-entry under plain Node against the asar-unpacked layout,
// so a bundling / asar-unpack regression fails packaging instead of reaching
// users. Module-load proof only: with no args the entry must reach argv parsing
// and print its "Usage: daemon-entry" error — a MODULE_NOT_FOUND or a missing
// usage line means the packaged graph does not load and the build must fail.
//
// resourcesDir is the packaged Resources dir (Contents/Resources on macOS,
// <appOutDir>/resources elsewhere). execPath defaults to the packaging Node.
function verifyPackagedDaemonEntryBoots(resourcesDir, options = {}) {
const execPath = options.execPath || process.execPath
const entryPath = join(resourcesDir, 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js')
if (!existsSync(entryPath)) {
// Why: some targets/layouts do not unpack here; skip rather than fail so
// the hook stays safe across platforms it has not verified.
console.log(`[verify-packaged-daemon-entry] skipped — no unpacked entry at ${entryPath}`)
return
}
const result = spawnSync(execPath, [entryPath], { encoding: 'utf8', timeout: 10_000 })
if (result.error) {
throw new Error(
`[verify-packaged-daemon-entry] could not launch daemon-entry.js: ${result.error.message}`
)
}
const stderr = result.stderr || ''
if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) {
throw new Error(
`[verify-packaged-daemon-entry] packaged daemon-entry.js failed to load under plain Node:\n${stderr}`
)
}
if (!stderr.includes('Usage: daemon-entry')) {
throw new Error(
`[verify-packaged-daemon-entry] packaged daemon-entry.js did not reach argv parsing ` +
`(expected the "Usage: daemon-entry" error). stderr:\n${stderr}`
)
}
console.log('[verify-packaged-daemon-entry] OK — packaged daemon-entry loads under plain Node')
}
module.exports = { verifyPackagedDaemonEntryBoots }
+3
View File
@@ -13,9 +13,12 @@
"../src/main/codex/codex-config-mirror.ts",
"../src/main/codex/codex-config-path-reference-rewrite.ts",
"../src/main/codex/codex-home-paths.ts",
"../src/main/codex/codex-hook-identity.ts",
"../src/main/codex/config-settings-promotion.ts",
"../src/main/codex/config-toml-line-scan.ts",
"../src/main/codex/config-toml-trust.ts",
"../src/main/codex/hook-service.ts",
"../src/main/codex/hook-trust-promotion.ts",
"../src/main/codex-accounts/fs-utils.ts",
"../src/main/command-code/command-code-managed-script.ts",
"../src/main/command-code/hook-service.ts",
+1
View File
@@ -2,6 +2,7 @@
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": [
"../electron.vite.config.*",
"../build-plugins/**/*",
"../src/main/**/*",
"../src/preload/**/*",
"../src/shared/**/*",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.6m">
<title>downloads: 3.6m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 4.1m">
<title>downloads: 4.1m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">3.6m</text>
<text x="93.5" y="14">3.6m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">4.1m</text>
<text x="93.5" y="14">4.1m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

+2 -2
View File
@@ -36,7 +36,7 @@
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -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.22/app-release.apk)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk)
---
+2 -2
View File
@@ -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.22/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.25/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -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.22/app-release.apk)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk)
---
+2 -2
View File
@@ -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.22/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.25/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -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.22/app-release.apk)
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk)
---
+2 -6
View File
@@ -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.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/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.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -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.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
- **Android:** [Baixar APK 0.0.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk)
---
@@ -238,10 +238,6 @@ Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
- **Discord:** Entre na comunidade no **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X:** Siga **[@orca_build](https://x.com/orca_build)** para atualizações e anúncios.
- **WeChat:** Escaneie o QR code para entrar na comunidade.
<img src="../assets/wechat-qr.png" alt="QR code do WeChat para a comunidade Orca" width="160" />
- **Feedback e ideias:** Lançamos rápido. Sentiu falta de algo? [Peça um novo recurso](https://github.com/stablyai/orca/issues).
- **Privacidade:** Veja a [documentação de privacidade e telemetria](https://www.onorca.dev/docs/telemetry) para saber quais dados anônimos de uso o Orca coleta e como desativar.
- **Mostre apoio:** Dê uma [estrela](https://github.com/stablyai/orca) neste repositório para acompanhar nossos lançamentos diários.
+7 -2
View File
@@ -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.22/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.25/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -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.22/app-release.apk)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk)
---
@@ -235,6 +235,11 @@ yay -S stably-orca-bin
- **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。
- **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。
- **微信:** 扫描二维码加入社区。如果第一个群已满,请使用备用微信群。
<img src="../assets/wechat-qr.png" alt="Orca 社区微信群二维码" width="160" />
<img src="../assets/wechat-qr-backup.jpg" alt="Orca 社区备用微信群二维码" width="160" />
- **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。
- **隐私:** 查看[隐私与遥测文档](https://www.onorca.dev/docs/telemetry),了解 Orca 收集哪些匿名使用数据以及如何退出。
- **支持我们:** 给这个仓库点 [Star](https://github.com/stablyai/orca),关注我们的日常发布。
@@ -0,0 +1,115 @@
# SSH Typing Latency Under Relay Load
Date: 2026-07-06
## Symptom
Typing in an SSH-host terminal can become extremely slow (hundreds of ms to
seconds per echoed keystroke) while other SSH work is active — file previews,
source-control refresh, search. A quiet SSH shell stays snappy, which made the
reports look unreproducible.
This is distinct from the local warm-switch lag documented in
`terminal-switch-typing-lag-investigation.md` (daemon `listSessions()`
snapshot cost — already fixed; `TerminalHost.listSessions()` is now
metadata-only via `getAppliedSize()`).
## Root Cause
The relay and the Electron client share ONE ordered SSH channel for all
JSON-RPC traffic: PTY input/output, file streams, git responses, search, port
scans. Two mechanisms turned bulk traffic into typing latency:
1. **Relay-side head-of-line blocking (primary).** The `fs.readFileStream`
pump (`src/relay/fs-handler-file-read.ts`) wrote every 256KB chunk (~340KB
framed) into the relay's stdout as fast as local disk reads completed,
ignoring the `write() === false` backpressure signal. A 10MB preview
enqueued ~13.6MB into the pipe at once; a `pty.data` echo emitted
mid-stream queued behind ALL of it. At 2MB/s WAN that is multiple seconds
of echo delay per open file. The reproduced measurement: 4,195,592 bytes
(the entire remaining 3MB test file, framed) queued ahead of one echo
frame.
2. **Client-side O(n²) frame buffering (secondary).** `FrameDecoder.feed()`
in both `src/main/ssh/relay-protocol.ts` and `src/relay/protocol.ts` did
`Buffer.concat([buffered, chunk])` per data event, re-copying the whole
backlog for every ~32KB TCP chunk — ~2MB of memcpy per 340KB frame,
~80MB per 10MB file — on the Electron main thread, delaying `pty:write`
IPC dispatch and echo delivery to the renderer.
## Fix
- **Bulk lane with sink backpressure** (`RelayDispatcher.notifyBulk`):
`fs.streamChunk` frames are serialized per client and each send waits for
the sink's `drain` when `write()` returns `false`. Interactive frames
(`pty.data`) still use plain `notify()` and are admitted immediately —
because the bulk lane keeps the outbound buffer at ~1 frame, an echo jumps
ahead of every not-yet-admitted chunk. Sinks (`relay.ts` stdout + Unix
socket clients) surface `write()`'s boolean and a one-shot
`waitWriteDrain`; every death path (EPIPE, stdin end, detach, dispose)
flushes parked waiters so a pump can never hang on a dead pipe.
- **Credit-based flow control** (`fs.streamAck`): the client requests
streams with `flowControl: 'ack'` and acks each processed chunk; the relay
caps unacked chunks at `STREAM_ACK_WINDOW_CHUNKS = 4` (~1MB raw). This
bounds in-flight bulk bytes even past the relay's own pipe (sshd/TCP
buffers) and paces the relay's base64/JSON encode loop so incoming
keystroke frames get event-loop turns. A parked pump wakes on ack, cancel,
release, client detach, and a 1s staleness recheck.
- **Cross-version compatibility**: old client + new relay → no
`flowControl` param → legacy unpaced pump (still drain-bounded, which is
transparent). New client + old relay → `fs.streamAck` is an unknown
notification and is silently ignored. `STREAM_CHUNK_SIZE` (256KB) is
intentionally unchanged — both sides bake it into chunk offset math, so
changing it would corrupt cross-version streams.
- **FrameDecoder rewrite** (both mirrored protocol files): chunk-list
buffering; each frame is assembled exactly once (one copy) instead of
re-concatenating the backlog per feed.
## What Stays Fast / Unchanged
- Relay PTY output batching (interactive echo fast path in
`src/relay/pty-handler.ts`) is untouched.
- No `listSessions()` / provider inventory was added anywhere near typing,
focus, switch, resume, resize, or render paths.
- Remote file streaming correctness: per-chunk length checks, chunk-count and
byte-count invariants, cancel paths, and the 12MB round-trip integration
test all pass unchanged.
## Regression Coverage
- `src/relay/fs-stream-pty-echo-backpressure.integration.test.ts` — real
mux ↔ dispatcher ↔ FsHandler over a congestible in-memory pipe; asserts
deterministic BYTE bounds (not wall-clock): echo queues behind < 2 framed
chunks when congested (pre-fix: whole file), ack window caps in-flight
chunks, legacy no-ack clients still get complete streams.
- `src/relay/fs-handler-stream.test.ts` — pump parks at the ack window,
resumes per ack, and releases its file handle when cancelled while parked.
- `src/relay/dispatcher.test.ts` — notifyBulk semantics: drain gating,
per-client targeting, dispose releases parked senders, interactive
notify() not gated behind a stalled bulk lane.
- `src/main/ssh/relay-protocol.test.ts` — decoder byte-at-a-time boundary
straddling, oversized-frame resync across odd chunk sizes, and a
no-`Buffer.concat`-during-feed guard that locks out the O(n²) shape.
- `tests/e2e/ssh-docker-relay-perf.spec.ts` — new "busy relay" scenario:
types while two 8MB remote file-read loops and a git.status loop run;
budgets median < 500ms, worst < 2000ms. (On loopback Docker the pre-fix
HOL is only tens of ms — the in-process byte-bound test is the
authoritative red/green; the e2e guards the end-to-end wiring.)
## Residual Gaps
- **Large single-frame responses**: `git.*` responses (stdout capped at
`MAX_GIT_BUFFER` = 10MB → ~13MB framed) and other request/response results
are one atomic frame on the wire; a huge diff can still delay an echo by
its own transfer time. Fixing this requires response chunking (a protocol
change); bounded by the 16MB `MAX_MESSAGE_SIZE`.
- **Outbound direction**: a large `fs.writeFile` request frame (client →
relay) can queue ahead of keystroke `pty.data` frames on `channel.stdin`.
Same shape, opposite direction, rarer trigger (saving a large remote file
while typing).
- `pty.ackData` flow control for PTY output remains unenforced
(`src/relay/pty-handler.ts` — "not yet enforced"); PTY output floods are
already paced by the relay's 8ms batch/16KB slice scheduler.
@@ -0,0 +1,368 @@
# Source Control Push Failure AI Recovery
## Problem
Push, force-push, publish, and sync push-stage failures flow through the shared
remote operation formatter in `src/renderer/src/lib/source-control-remote-error.ts:94`
and the editor-store toast paths in `src/renderer/src/store/slices/editor.ts:3716`
and `src/renderer/src/store/slices/editor.ts:3821`. A local pre-push hook or
lint hook is not an auth, protected-branch, or transport problem, so generic
remote guidance sends the user in the wrong direction.
PR #7787 proves the behavior is useful, but the prototype needs tightening before
it is maintainable:
- `src/renderer/src/components/right-sidebar/SourceControl.tsx:1938` and
`src/renderer/src/components/right-sidebar/SourceControl.tsx:5799` repeat
push-failure predicates and feed live branch/file state into a prompt that says
"at failure time".
- `src/renderer/src/components/right-sidebar/use-source-control-recovery-ai.ts:63`
imports push detection again instead of consuming one derived recovery model.
- `src/renderer/src/components/right-sidebar/source-control-ai-commit-failure-launch.ts:24`
and `src/renderer/src/components/right-sidebar/source-control-ai-push-failure-launch.ts:24`
are copied launch flows with action-specific copy.
- `src/shared/source-control-commit-failure-agent-command.ts:6` and
`src/shared/source-control-push-failure-agent-command.ts:6` are identical
command-template builders.
- `src/renderer/src/components/right-sidebar/source-control-fix-split-button.tsx:21`
already contains a reusable split button, while `SourceControl.tsx:6415`
carries a second inline version.
- `src/renderer/src/lib/source-control-remote-error.ts:148` currently treats
any `isSync` error as push-like for hook detection. `syncBranch` calls the
formatter for fetch, upstream-status, pull, and push failures, so the renderer
cannot prove a sync error came from the push stage without an explicit marker.
- `src/renderer/src/components/right-sidebar/SourceControl.push-failure-recovery.test.ts:2`
imports prompt helpers through the large React module instead of shared prompt
modules.
## Goal
Add first-class recovery for pre-push hook failures without growing a parallel
push-only subsystem:
1. Toasts for push, force-push, publish, and sync push-stage failures say
"blocked" for detected pre-push or lint-hook output.
2. Source Control shows a concise Push blocked panel with Details and AI Fix
only for a failure snapshot that is both push-like and hook-like.
3. AI Fix launches the configured `fixPushFailure` Source Control action with a
safe, provider-neutral prompt.
4. Commit-failure recovery and push-failure recovery share launch,
command-template, split-button, and dense error-panel primitives.
## Non-goals
- Do not fix server-side pre-receive hooks, hosted CI failures, or provider-side
protected-branch failures from this entry point.
- Do not bypass hooks, add `--no-verify`, push from the launched agent, create a
PR, or assume GitHub-specific terminology.
- Do not add app-wide persistence for this transient failure panel.
- Do not redesign the Source Control panel or the Source Control AI settings
model.
- Do not fold unrelated launch surfaces such as checks recovery into this change
unless a tiny shared helper is already needed for commit/push recovery.
## Design
1. Keep push-hook detection and prompt building in shared code.
- `src/shared/source-control-push-failure.ts` owns normalization, bounded
scanning, summary text, Details eligibility, prompt truncation, prompt
rules, and changed-file list bounding.
- Detection must stay conservative: explicit `pre-push`/`prepush`,
`hook declined to push`, hook-runner output in push context, or lint output
in push context. Auth, non-fast-forward, protected branch, pre-receive,
submodule, and generic transport failures stay on the existing remote-error
path.
- Keep the output scan bounded at 64 KiB and the prompt failure-output
section bounded. Also cap prompt file lines to a small constant with an
omitted-count line; do not rely on the git-status cap, which is far too high
for prompt context.
2. Make remote-error formatting sync-stage aware.
- Extend `RemoteOperationErrorOptions` with an explicit push-stage flag for
sync, for example `isSyncPushStage`.
- Gate push-hook classification on `isPush`, `isForcePush`, `publish`, or
`isSyncPushStage`; do not use bare `isSync` as evidence of a push failure.
- In `syncBranch`, pass the push-stage flag only inside the two inner
`pushRuntimeGit` catch blocks. The outer fetch, upstream-status, and pull
catches still pass `isSync` for Sync-shaped generic copy but must not render
blocked hook copy.
- Carry the same push-stage fact back to the renderer, either by tagging the
thrown `Error` with a narrow exported marker or by wrapping it in a typed
error that preserves the original message/cause. `SourceControl` must set
`syncPushStage` only from that marker, not from `kind === 'sync'` plus
hook-like stderr.
- Keep submodule push messages before hook detection so submodule guidance
remains more specific than AI recovery.
3. Replace one-off command builders with one shared recovery command builder.
- Add a shared builder that accepts `{ actionId, promptOverride,
commandInputTemplate, basePrompt }` and renders the existing Source Control
AI command template for launch actions.
- Keep compatibility exports for commit and push helpers if nearby tests or
call sites still use those names, but make them wrappers over the generic
builder.
- Move prompt-builder tests to import from shared modules, not
`SourceControl.tsx`.
4. Capture and derive push recovery state once.
- Extend `SourceControlActionError` with the raw error, a push-stage marker
for sync failures, the branch name at failure time, a bounded status-entry
snapshot at failure time, and a per-worktree sequence token.
- `runRemoteAction` clears the worktree's previous error and increments its
sequence before starting. A catch may write an error only if its sequence
still owns that worktree, so a slow failure cannot overwrite a newer retry
or success.
- Add a focused renderer helper that accepts the captured
`SourceControlActionError` and the current branch name, then returns either
`null` or a model with raw/sanitized detail text, summary, details flag,
kind label, and AI prompt.
- The helper returns `null` unless the operation is `push`, `force_push`,
`publish`, or `sync` with the sync push-stage marker and
`isPushHookFailure(rawError)` passes.
- If the current branch is known and differs from the captured branch, hide or
clear the model instead of launching an agent with wrong branch context.
- Pass that single model to `useSourceControlAi` and `CommitArea`; neither
prop construction nor `CommitArea` should repeat the push-hook predicate.
5. Use one generic recovery launcher for commit and push failures.
- Replace the separate commit and push launch files with
`source-control-ai-recovery-launch.ts`, or keep the old files as one-line
wrappers if import stability is cheaper.
- The generic launcher owns connection resolution, SSH/local agent discovery,
saved-agent validation, agent-args validation, agent selection, terminal
launch, focus, and success/failure toasts.
- Resolve connection without collapsing local and unresolved states: read
`const worktreeConnectionId = getConnectionId(worktreeId)`, use it when it
is a string or `null`, and only fall back to `sourceRepoConnectionId` when
the worktree lookup returns `undefined`. If the fallback is also
`undefined`, show the workspace-connection error instead of launching
locally. `null` means proven local; a string means SSH/remote.
- Validate saved CLI args before agent detection or terminal creation. On
Windows, keep using PowerShell-safe planning through
`planAgentCliArgsSuffix(..., 'powershell')`.
- Action-specific data is limited to action id, base prompt, empty-prompt
copy, unavailable-agent copy, and success copy.
6. Keep the recovery hook thin.
- `use-source-control-recovery-ai.ts` builds the commit prompt, accepts the
already-derived push recovery prompt/model, keeps independent loading
flags, and calls the generic launcher.
- It should not import push-hook detection, resolve agents, or duplicate
launch plumbing.
7. Extract and reuse the recovery UI.
- Reuse or replace the existing
`src/renderer/src/components/right-sidebar/source-control-fix-split-button.tsx`
rather than adding another split-button component.
- Move the dense recovery notice and Details dialog into focused renderer
modules named for source-control recovery. Avoid `helpers`, `utils`, and new
max-lines suppressions.
- Reuse the same notice/dialog component for commit and push recovery with
action-specific labels, summary, details, prompt, saved recipe, and launch
callback.
- Keep normal remote errors filtered out when a push recovery model is
rendered, so the user does not see both Push blocked and generic remote
error copy for the same failure.
8. Retain `fixPushFailure` in the existing Source Control AI action model.
- The prototype already adds `fixPushFailure` to
`src/shared/source-control-ai-actions.ts`; keep it as a launch action like
`fixCommitFailure`.
- Verify default settings, normalizers, global settings rows, repository
override rows, labels, descriptions, and variable chips all include Push
failure fixes.
- Do not create a push-specific settings store or migration. Existing
`normalizeSourceControlAiSettings` hydrates missing action defaults.
## Data Flow
- User clicks Push, Force Push, Publish Branch, or Sync.
- `runRemoteAction` clears that worktree's previous remote error, records a new
sequence token, and starts the editor-store remote action.
- Git push fails and throws.
- `pushBranch` or sync's inner push-stage catch calls
`resolveRemoteOperationErrorMessage` with push-like options. Sync fetch, pull,
and upstream-status failures do not pass the push-stage flag.
- Sync's inner push-stage catch marks the rethrown error as push-stage so
`runRemoteAction` can preserve `syncPushStage` in the captured snapshot.
- The formatter classifies hook output before auth/transport fallbacks only for
push-like options and shows blocked toast copy.
- `SourceControl` catches the same error, and if the sequence still owns the
worktree, stores `{ kind, rawError, message, syncPushStage, branchName,
entriesSnapshot }`.
- The push recovery helper derives one `pushRecovery` model from that snapshot.
- `CommitArea` renders normal remote errors or the Push blocked recovery notice.
- AI Fix builds the `fixPushFailure` command input and launches the selected
agent in the owning local or SSH runtime.
## Edge Cases
- Auth, missing repo, protected-branch, pre-receive, non-fast-forward, and
transport failures must not show the Push blocked panel.
- Submodule push errors keep their existing specialized messages before
push-hook detection.
- Sync failures from fetch, upstream-status, or pull stages must not show Push
blocked, even if stderr contains words like "lint" or "hook".
- Create PR intent remote failures use the same `runRemoteAction` plumbing; if
CommitArea is not visible, blocked toast copy is still required but no hidden
panel work is needed.
- A newer remote operation for the same worktree must clear stale recovery state
immediately and prevent an older in-flight failure from writing after it.
- Switching worktrees or branches must not show another worktree or branch's
push failure.
- External edits or another Orca window may make the failure stale. Do not add
cross-window persistence for this feature; rely on local retry/remount/branch
mismatch clearing and list this as residual risk.
- Source Control AI hidden for a repo still shows the blocked notice and Details;
it hides AI Fix.
- SSH worktrees must detect and launch agents on the owning connection, not a
local fallback.
- Windows launch planning must continue to use PowerShell-safe agent args.
- Very large hook output and very large changed-file sets must be bounded before
prompt generation.
- ANSI/control output must not leak into summaries or Details comparison logic.
Details may show sanitized text while preserving enough line breaks for
debugging.
- Empty custom command templates must stay empty so the launcher rejects them
with a clear settings error.
- Prompt text must treat file paths, branch names, and hook output as data, not
instructions.
## Test Plan
- Shared unit tests:
- `src/shared/source-control-push-failure.test.ts` covers detection positives,
auth/protected/pre-receive/non-fast-forward negatives, ANSI/control
stripping, bounded scanning, details comparison, prompt output truncation,
prompt file-list capping, and provider-neutral prompt rules.
- Shared command-template tests cover the generic recovery builder plus
commit/push wrappers, including empty templates and prompt overrides.
- `src/shared/source-control-ai-actions.test.ts` covers `fixPushFailure`
label, default template, variables, normalization, and action-list inclusion.
- Remote formatter and store tests:
- `src/renderer/src/lib/source-control-remote-error.test.ts` covers push,
force-push, publish, and sync push-stage blocked copy plus sync non-push
stage negatives, auth/non-fast-forward/protected/pre-receive/submodule
regressions.
- `src/renderer/src/store/slices/editor.test.ts` covers the `pushBranch` toast
path and both sync push-stage and sync non-push-stage toasts.
- Renderer model and UI tests:
- A focused push recovery derivation test covers operation kind, sync
push-stage marker, branch mismatch, stale sequence ownership, snapshot file
entries, summary, details flag, prompt contents, and ordinary remote errors.
- `CommitArea` tests cover the Push blocked notice, Details dialog, AI Fix
visible/hidden, ordinary remote errors, no duplicate remote error when a push
model exists, and unchanged commit-failure recovery rendering.
- Settings tests cover global and repository Source Control AI action rows
showing Push failure fixes.
- Launcher tests:
- Generic recovery launcher tests cover commit and push action ids, invalid CLI
args before detection, empty template rejection, unavailable saved agent,
local versus SSH agent detection, successful launch/focus, and success copy.
- Checks:
- Run targeted vitest files above, `pnpm typecheck`, `pnpm lint`, and
`pnpm check:max-lines-ratchet`.
## UI Quality Bar
The Source Control recovery UI must match `docs/STYLEGUIDE.md`: monochrome,
quiet, dense, token-based, and consistent with adjacent commit-failure recovery.
Use existing shadcn `Button`, `DropdownMenu`, and `Dialog` primitives, lucide
icons, `card`/`border`/`destructive`/`muted` tokens, and the existing action
recipe row patterns. The Push blocked notice should keep the summary readable at
narrow right-sidebar widths, avoid nested cards, avoid new color values, show
stable split-button geometry while launching, and keep Details as progressive
disclosure for long hook output. Dialogs and dropdowns must remain usable in
light/dark mode and under SSH latency.
## Review Screenshots
1. Source Control after a simulated pre-push hook failure: Push blocked notice
with summary, AI Fix, and Details.
2. Push failure Details dialog showing hook output and Fix with AI.
3. AI Fix customize-launch dialog for `fixPushFailure`.
4. Ordinary push auth/protected/remote failure state: regular remote error, no
Push blocked notice.
5. Sync fetch/pull-stage failure state: Sync-shaped remote error, no Push
blocked notice.
6. Source Control AI hidden for the repo: Push blocked notice and Details, no AI
Fix.
7. Source Control AI settings showing the Push failure fixes launch recipe.
8. Adjacent commit-failure recovery notice still rendering correctly after the
shared UI extraction.
## Rollout
1. Tighten shared push-failure classifier/prompt bounds and add the generic
recovery command builder.
2. Make remote formatter/store sync-stage aware and update toast tests.
3. Add the `SourceControlActionError` snapshot/sequence data and one push
recovery derivation helper.
4. Replace duplicated commit/push launchers with the generic recovery launcher
and thin hook wiring.
5. Extract/reuse source-control recovery UI components, including the existing
split-button module.
6. Verify `fixPushFailure` settings/default/normalizer coverage and i18n labels.
7. Update targeted tests and run typecheck, lint, and max-lines ratchet.
8. Validate rendered Electron states with screenshots before opening the PR.
## Lightweight Eng Review
- Scope: Reduced from PR #7787's push-specific parallel path to a shared
recovery layer. Keep the feature limited to local pre-push/lint-hook detection,
blocked copy, an inline recovery notice, Details, and AI launch. No persistence
or provider-specific recovery.
- Architecture/data flow: Shared code owns classification, prompt text, and
command-template rendering; the editor store owns stage-aware toast formatting;
`SourceControl` owns transient worktree-scoped failure snapshots and sequence
invalidation; the generic launcher owns local/SSH agent discovery and terminal
launch.
- Failure modes covered:
- Sync stage ambiguity: only the inner push stage may classify as hook-blocked.
- Auth/protected/pre-receive/non-fast-forward/submodule/transport errors stay
out of push recovery.
- Branch or worktree switches suppress stale recovery models.
- Per-worktree sequence tokens prevent older in-flight failures from
overwriting newer retries or successes.
- Empty custom templates, invalid CLI args, unavailable saved agents, missing
worktree context, SSH host detection, and Windows shell planning fail before
terminal launch.
- Large or hostile hook output and file lists are bounded and treated as data
in prompts.
- Test coverage required:
- Shared classifier/prompt/command/action-model tests in `src/shared`.
- Remote formatter and editor-store tests for push, force-push, publish, sync
push-stage, and sync non-push-stage paths.
- Renderer derivation and `CommitArea` tests for Push blocked, Details, AI Fix,
hidden AI actions, stale branch/sequence, ordinary remote errors, and
existing commit failure recovery.
- Generic launcher tests for local/SSH, invalid args, saved-agent unavailable,
empty template, and success launch.
- Settings tests for global and repo Push failure fixes rows.
- Electron validation for all required screenshots; no separate E2E is
required if renderer tests cover the model and Electron validates the real UI.
- Performance/blast radius: No polling, watchers, migrations, or startup work.
Runtime cost is one bounded classifier pass over at most 64 KiB, bounded prompt
output, and a bounded file snapshot only when a remote action fails. Largest
blast radius is the shared recovery UI and launcher used by existing commit
failures, so commit recovery needs tests and a screenshot.
- UI quality bar: Match `docs/STYLEGUIDE.md` and adjacent Source Control density:
token-based error state, stable split-button geometry, progressive Details
disclosure, no nested cards, concise copy, light/dark compatibility, and
visible disabled/loading state under SSH latency.
- Required review screenshots:
1. Push blocked notice after pre-push hook failure.
2. Push failure Details dialog with Fix with AI.
3. `fixPushFailure` customize-launch dialog.
4. Ordinary push auth/protected/remote failure with no Push blocked panel.
5. Sync non-push-stage failure with no Push blocked panel.
6. Source Control AI hidden: Push blocked without AI Fix.
7. Source Control AI settings showing Push failure fixes.
8. Existing commit-failure recovery notice after shared UI extraction.
- Residual risks: Classifier false positives remain possible for unusual local
hook-runner output that mentions push context; keep tests biased toward
preserving auth, protected-branch, and non-fast-forward behavior. Failure
panels are renderer-local, so another Orca window or an external terminal can
fix the problem while the old panel remains visible until local retry, remount,
or branch/worktree change. Electron validation may need a mocked or temporary
repo scenario to trigger a real pre-push hook without mutating user data.
+5 -1
View File
@@ -2,6 +2,7 @@ import { resolve } from 'node:path'
import { defineConfig } from 'electron-vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { createPlainNodeEntryGuardPlugin } from './build-plugins/plain-node-entry-guard'
// Why: the telemetry transport is gated by two compile-time constants that
// only the official CI release workflow sets. Contributor / `pnpm dev` /
@@ -182,13 +183,16 @@ export default defineConfig({
'stt-worker': resolve('src/main/speech/stt-worker.ts'),
'warp-theme-parser-worker': resolve('src/main/warp-themes/warp-theme-parser-worker.ts'),
'file-watcher-worker': resolve('src/main/runtime/file-watcher-worker.ts'),
// Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults
// can't take down the main process (issue #7547).
'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'),
// Why: electron-vite cleans out/main in dev. The dev CLI imports
// this path for `orca agent hooks ...`, so it must survive rebuilds.
'agent-hooks/managed-agent-hook-controls': resolve(
'src/main/agent-hooks/managed-agent-hook-controls.ts'
)
},
plugins: [createStartupDiagnosticsBootstrapPlugin()]
plugins: [createStartupDiagnosticsBootstrapPlugin(), createPlainNodeEntryGuardPlugin()]
}
},
// Why: compile-time substitution for the telemetry gate. See the block
+2 -2
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.22",
"version": "0.0.25",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -75,7 +75,7 @@
"allowBackup": false,
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
"package": "com.stably.orca.mobile",
"versionCode": 4
"versionCode": 6
},
"plugins": [
"expo-router",
+8
View File
@@ -102,6 +102,14 @@ export default function RootLayout() {
return
}
handledNotificationIdsRef.current.add(notificationId)
// Why: RootLayout never unmounts, so cap this tap-dedup set (FIFO) rather
// than letting it grow one id per notification tapped for the app's life.
if (handledNotificationIdsRef.current.size > 256) {
const oldest = handledNotificationIdsRef.current.values().next().value
if (oldest !== undefined) {
handledNotificationIdsRef.current.delete(oldest)
}
}
const path = await getNavigationPath(response.notification.request.content.data)
clearLastNotificationResponse()
+43 -8
View File
@@ -3,14 +3,15 @@ import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from '
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react-native'
import { useHostClient } from '../../../../src/transport/client-context'
import { useForceReconnect, useHostClient } from '../../../../src/transport/client-context'
import type { RpcSuccess } from '../../../../src/transport/types'
import { colors, spacing, typography } from '../../../../src/theme/mobile-theme'
import { colors, radii, spacing, typography } from '../../../../src/theme/mobile-theme'
import {
fetchMobileGitHistory,
mapMobileCommitRows,
type MobileCommitRow
} from '../../../../src/source-control/mobile-git-history'
import { resolveMobileHistoryScreenView } from '../../../../src/source-control/mobile-history-screen-state'
import type { GitBranchChangeEntry } from '../../../../../src/shared/types'
function firstParam(value: string | string[] | undefined): string {
@@ -27,9 +28,11 @@ export default function HistoryScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const { client, state: connState } = useHostClient(hostId)
const forceReconnect = useForceReconnect()
const [rows, setRows] = useState<MobileCommitRow[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [reloadNonce, setReloadNonce] = useState(0)
const [expanded, setExpanded] = useState<string | null>(null)
const [filesById, setFilesById] = useState<Record<string, GitBranchChangeEntry[] | 'loading'>>({})
@@ -57,7 +60,20 @@ export default function HistoryScreen() {
return () => {
active = false
}
}, [client, connState, worktreeId])
}, [client, connState, reloadNonce, worktreeId])
const retry = useCallback(() => {
setError(null)
// Why: retrying the fetch is useless while the transport's reconnect loop
// is parked at its backoff cap — revive the connection instead (mirrors
// MobileSourceControlPanel / issue #5049). The load effect re-runs via
// connState once the fresh client connects.
if (connState !== 'connected' && hostId) {
void forceReconnect(hostId)
return
}
setReloadNonce((n) => n + 1)
}, [connState, forceReconnect, hostId])
const toggleCommit = useCallback(
(row: MobileCommitRow) => {
@@ -130,6 +146,12 @@ export default function HistoryScreen() {
[expanded, filesById, toggleCommit]
)
const view = resolveMobileHistoryScreenView({
connected: client !== null && connState === 'connected',
rows,
error
})
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
@@ -138,21 +160,26 @@ export default function HistoryScreen() {
</Pressable>
<Text style={styles.title}>Commit History</Text>
</View>
{error ? (
{view.kind === 'error' || view.kind === 'waiting' ? (
<View style={styles.state}>
<Text style={styles.stateText}>{error}</Text>
<Text style={styles.stateText}>
{view.kind === 'waiting' ? 'Waiting for desktop...' : view.message}
</Text>
<Pressable style={styles.retryButton} onPress={retry} accessibilityLabel="Retry">
<Text style={styles.retryText}>Retry</Text>
</Pressable>
</View>
) : rows === null ? (
) : view.kind === 'loading' ? (
<View style={styles.state}>
<ActivityIndicator color={colors.textSecondary} />
</View>
) : rows.length === 0 ? (
) : view.kind === 'empty' ? (
<View style={styles.state}>
<Text style={styles.stateText}>No commits.</Text>
</View>
) : (
<FlatList
data={rows}
data={view.rows}
renderItem={renderCommit}
keyExtractor={(row) => row.id}
contentContainerStyle={{ paddingBottom: spacing.lg + insets.bottom }}
@@ -175,6 +202,14 @@ const styles = StyleSheet.create({
title: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' },
state: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.lg },
stateText: { color: colors.textMuted, fontSize: typography.bodySize },
retryButton: {
marginTop: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radii.button,
backgroundColor: colors.bgRaised
},
retryText: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' },
commit: { borderBottomWidth: 1, borderBottomColor: colors.borderSubtle },
commitHeader: {
flexDirection: 'row',
+45 -3
View File
@@ -24,6 +24,7 @@ import {
AlertTriangle,
ArrowUp,
Bot,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsRight,
@@ -100,6 +101,7 @@ import {
isTerminalLiveInputWithinByteLimit,
scheduleTerminalLiveInputFocus
} from '../../../../src/terminal/terminal-live-input'
import { dismissTerminalKeyboard } from '../../../../src/terminal/terminal-keyboard-dismiss'
import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender'
import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response'
import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit'
@@ -1006,6 +1008,7 @@ export default function SessionScreen() {
const viewportMeasuredRef = useRef(false)
const terminalRefs = useRef<Map<string, TerminalWebViewHandle>>(new Map())
const liveInputRef = useRef<TextInput>(null)
const commandInputRef = useRef<TextInput>(null)
const liveInputFocusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const sendLiveTerminalInputRef = useRef<TerminalLiveInputSender>(async () => false)
const sessionTabActionSheetKeyboardHideSubRef = useRef<ReturnType<
@@ -3208,6 +3211,15 @@ export default function SessionScreen() {
]
)
const dismissSoftwareKeyboard = useCallback(() => {
dismissTerminalKeyboard({
clearPendingLiveInputFocus: () => clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef),
commandInput: commandInputRef.current,
dismissKeyboard: () => Keyboard.dismiss(),
liveInput: liveInputRef.current
})
}, [])
const handleTerminalTap = useCallback(
(handle: string) => {
if (handle !== activeHandleRef.current) {
@@ -4780,10 +4792,38 @@ export default function SessionScreen() {
>
{/* Accessory keys */}
<View style={styles.accessoryBar}>
{/* Why: a fixed, always-visible escape hatch from the open
keyboard. Kept outside the horizontal ScrollView so it does
not scroll away, and out of the terminal-byte shortcut path so
it cannot be hidden by user shortcut customization (#5106). */}
{keyboardLift > 0 && (
<Pressable
style={({ pressed }) => [
styles.keyboardDismissKey,
pressed && styles.accessoryKeyPressed
]}
onPress={dismissSoftwareKeyboard}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Dismiss keyboard"
accessibilityHint="Hides the software keyboard and keeps the current terminal session open."
>
<View style={styles.keyboardDismissGlyph}>
<KeyboardIcon size={15} color={colors.textSecondary} strokeWidth={2} />
<ChevronDown
size={10}
color={colors.textSecondary}
strokeWidth={2.5}
style={styles.keyboardDismissChevron}
/>
</View>
</Pressable>
)}
{/* Why: with default tap handling the first tap on any accessory
key dismisses the open keyboard and is swallowed, so live
input lost its keyboard on every Esc/Tab press (#5106). */}
<ScrollView
style={styles.accessoryScroll}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.accessoryContent}
@@ -5009,6 +5049,7 @@ export default function SessionScreen() {
) : (
<View style={styles.inputBar}>
<TextInput
ref={commandInputRef}
// Why: Android caches the IME inputType at mount, so toggling
// autocomplete must remount there; iOS can update without a focus-costly remount.
key={
@@ -5020,9 +5061,10 @@ export default function SessionScreen() {
}
style={styles.textInput}
value={input}
onChangeText={(text) =>
setInput((previousText) => normalizeTerminalTextInput(text, previousText))
}
// Why: iOS kills an active dictation/IME session when JS
// writes a value that differs from the native field text;
// store the raw field text and normalize at send time.
onChangeText={setInput}
placeholder="Type a command…"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
@@ -68,10 +68,16 @@ export const mobileSessionCommandInputStyles = StyleSheet.create({
zIndex: 20
},
accessoryBar: {
flexDirection: 'row',
alignItems: 'center',
borderTopWidth: 1,
borderTopColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
accessoryScroll: {
flex: 1,
minWidth: 0
},
accessoryContent: {
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
@@ -110,6 +116,29 @@ export const mobileSessionCommandInputStyles = StyleSheet.create({
accessoryKeyTextDisabled: {
color: colors.textMuted
},
keyboardDismissKey: {
alignItems: 'center',
justifyContent: 'center',
marginLeft: spacing.sm,
marginVertical: spacing.xs,
backgroundColor: colors.bgRaised,
paddingHorizontal: spacing.sm + 2,
paddingVertical: 0,
borderRadius: radii.button,
minWidth: 36,
height: 28
},
keyboardDismissGlyph: {
alignItems: 'center',
height: 18,
justifyContent: 'flex-start',
position: 'relative',
width: 18
},
keyboardDismissChevron: {
bottom: -2,
position: 'absolute'
},
inputBar: {
flexDirection: 'row',
alignItems: 'center',
@@ -21,6 +21,31 @@ const MOCK_FILE_LIST = [
{ relativePath: 'dist/archive.zip', basename: 'archive.zip', kind: 'binary' }
]
function readMockDirectory(relativePath: string): Array<{ name: string; isDirectory: boolean }> {
const prefix = relativePath ? `${relativePath}/` : ''
const children = new Map<string, boolean>()
for (const file of MOCK_FILE_LIST) {
if (!file.relativePath.startsWith(prefix)) {
continue
}
const rest = file.relativePath.slice(prefix.length)
if (!rest) {
continue
}
const [name, ...descendants] = rest.split('/')
if (!name) {
continue
}
children.set(name, children.get(name) === true || descendants.length > 0)
}
return Array.from(children, ([name, isDirectory]) => ({ name, isDirectory })).sort((a, b) => {
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? -1 : 1
}
return a.name.localeCompare(b.name)
})
}
export function handleMockFilePreviewRequest(
request: RpcRequest,
respond: Respond,
@@ -40,6 +65,10 @@ export function handleMockFilePreviewRequest(
)
return true
case 'files.readDir':
respond(success(request.id, readMockDirectory(String(request.params?.relativePath ?? ''))))
return true
case 'files.read': {
const relativePath = String(request.params?.relativePath ?? '')
const content = MOCK_FILE_CONTENT[relativePath]
@@ -2,13 +2,14 @@ import { useEffect } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { ChevronLeft, X } from 'lucide-react-native'
import { ChevronLeft, ExternalLink, X } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
import type { ConnectionState } from '../../transport/types'
import type { RpcClient } from '../../transport/rpc-client'
import type { MobileGitStatusResult } from '../../source-control/mobile-git-status'
import { useMobilePrSidebarController } from '../../session/use-mobile-pr-sidebar-controller'
import { MobilePRSidebar } from '../MobilePRSidebar'
import { openMobilePrUrl } from '../MobilePrComposeSheet'
type Props = {
client: RpcClient | null
@@ -73,6 +74,27 @@ export function MobilePrViewPanel({
message: 'Current branch unavailable.'
} as const)
: controller.prSidebarState
// Why: open-on-host lives in the chrome so the PR URL is always flush-right of
// the screen title, even when the body is scrolled past the PR header.
const prUrl =
sidebarState.kind === 'ready' && sidebarState.data.pr.url ? sidebarState.data.pr.url : null
const prNumber = sidebarState.kind === 'ready' ? sidebarState.data.pr.number : null
const openPr = prUrl ? () => openMobilePrUrl(prUrl) : undefined
const openPrControl = openPr ? (
<Pressable
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
onPress={openPr}
hitSlop={8}
accessibilityRole="link"
accessibilityLabel={
prNumber != null
? `Open pull request #${prNumber} on the web`
: 'Open pull request on the web'
}
>
<ExternalLink size={18} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
) : null
const sidebar = (
<MobilePRSidebar
state={sidebarState}
@@ -104,6 +126,7 @@ export function MobilePrViewPanel({
<Text style={styles.title} numberOfLines={1}>
Pull Request
</Text>
{openPrControl}
</View>
</View>
{sidebar}
@@ -126,6 +149,7 @@ export function MobilePrViewPanel({
<Text style={styles.title} numberOfLines={1}>
Pull Request
</Text>
{openPrControl}
</View>
</View>
{sidebar}
@@ -29,8 +29,8 @@ export function PRSidebarHeader({ pr, details, titleAction }: Props) {
const baseRef = item?.baseRefName ?? null
const headRef = item?.branchName ?? null
const editable = canEditPRTitle(pr.state)
// Tapping the state badge or the #number opens the PR on its host (GitHub/etc.)
// in the phone browser — pr.url is the canonical web URL.
// Badge, #number, and the flush-right external-link control all open pr.url
// (canonical host web URL) in the phone browser.
const openPr = pr.url ? () => openMobilePrUrl(pr.url) : undefined
return (
@@ -0,0 +1,398 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileFileExplorerPanel } from './MobileFileExplorerPanel'
import type { MobileDirEntry } from './file-tree'
import type { RpcResponse } from '../transport/types'
type MockClient = {
sendRequest: ReturnType<typeof vi.fn>
}
const mockTransport = vi.hoisted(() => ({
client: null as MockClient | null,
connectionState: 'connected',
forceReconnect: vi.fn()
}))
vi.mock('react-native', async () => {
const React = await import('react')
return {
ActivityIndicator: 'ActivityIndicator',
FlatList: (props: {
data: unknown[]
keyExtractor: (item: unknown, index: number) => string
renderItem: (info: {
item: unknown
index: number
separators: Record<string, never>
}) => unknown
}) =>
React.createElement(
'FlatList',
props,
props.data.map((item, index) =>
React.createElement(
'FlatListItem',
{ key: props.keyExtractor(item, index) },
props.renderItem({ item, index, separators: {} })
)
)
),
Pressable: 'Pressable',
StyleSheet: {
create: (styles: unknown) => styles,
hairlineWidth: 1
},
Text: 'Text',
View: 'View'
}
})
vi.mock('react-native-safe-area-context', () => ({
SafeAreaView: 'SafeAreaView'
}))
vi.mock('expo-router', () => ({
useRouter: () => ({
back: vi.fn(),
push: vi.fn()
})
}))
vi.mock('lucide-react-native', () => ({
ChevronDown: 'ChevronDown',
ChevronLeft: 'ChevronLeft',
ChevronRight: 'ChevronRight',
File: 'File',
FileText: 'FileText',
Folder: 'Folder',
Image: 'Image',
X: 'X'
}))
vi.mock('../platform/haptics', () => ({
triggerSelection: vi.fn()
}))
vi.mock('../transport/client-context', () => ({
useForceReconnect: () => mockTransport.forceReconnect,
useHostClient: () => ({
client: mockTransport.client,
state: mockTransport.connectionState
})
}))
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => consoleErrorSpy.mockRestore()
}
function entry(name: string, isDirectory = false): MobileDirEntry {
return { name, isDirectory }
}
function ok(result: MobileDirEntry[]): RpcResponse {
return { id: 'response-id', ok: true, result, _meta: { runtimeId: 'runtime-id' } }
}
function createMockClient(entriesByPath: Record<string, MobileDirEntry[]>): MockClient {
return {
sendRequest: vi.fn(async (_method: string, params: { relativePath: string }) => {
return ok(entriesByPath[params.relativePath] ?? [])
})
}
}
async function renderExplorer(): Promise<ReactTestRenderer> {
let renderer: ReactTestRenderer | null = null
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileFileExplorerPanel, {
hostId: 'host-a',
worktreeId: 'worktree-a',
name: 'Example Worktree',
embedded: true
})
)
})
} finally {
restoreConsoleError()
}
if (!renderer) {
throw new Error('MobileFileExplorerPanel did not render')
}
return renderer
}
async function updateExplorer(renderer: ReactTestRenderer): Promise<void> {
await act(async () => {
renderer.update(
createElement(MobileFileExplorerPanel, {
hostId: 'host-a',
worktreeId: 'worktree-a',
name: 'Example Worktree',
embedded: true
})
)
})
}
async function pressByLabel(
renderer: ReactTestRenderer,
accessibilityLabel: string
): Promise<void> {
const pressable = renderer.root
.findAllByType('Pressable')
.find((node) => node.props.accessibilityLabel === accessibilityLabel)
if (!pressable) {
throw new Error(`Unable to find pressable: ${accessibilityLabel}`)
}
await act(async () => {
pressable.props.onPress()
})
}
function renderedText(renderer: ReactTestRenderer): string {
return renderer.root
.findAllByType('Text')
.flatMap((node) => node.props.children)
.join(' ')
}
describe('MobileFileExplorerPanel', () => {
afterEach(() => {
vi.restoreAllMocks()
mockTransport.client = null
mockTransport.connectionState = 'connected'
mockTransport.forceReconnect = vi.fn()
})
it('loads directories lazily and reuses cached children when reopened', async () => {
const client = createMockClient({
'': [entry('src', true), entry('README.md')],
src: [entry('app.ts'), entry('components', true)]
})
mockTransport.client = client
const renderer = await renderExplorer()
expect(client.sendRequest).toHaveBeenCalledTimes(1)
expect(client.sendRequest).toHaveBeenLastCalledWith('files.readDir', {
worktree: 'id:worktree-a',
relativePath: ''
})
await pressByLabel(renderer, 'Open folder src')
await vi.waitFor(() => expect(client.sendRequest).toHaveBeenCalledTimes(2))
expect(client.sendRequest).toHaveBeenLastCalledWith('files.readDir', {
worktree: 'id:worktree-a',
relativePath: 'src'
})
expect(renderedText(renderer)).toContain('app.ts')
await pressByLabel(renderer, 'Open folder src')
await pressByLabel(renderer, 'Open folder src')
expect(client.sendRequest).toHaveBeenCalledTimes(2)
})
it('keeps the loaded tree visible during a transient disconnect', async () => {
const client = createMockClient({
'': [entry('src', true), entry('README.md')]
})
mockTransport.client = client
const renderer = await renderExplorer()
expect(renderedText(renderer)).toContain('src')
mockTransport.client = null
mockTransport.connectionState = 'disconnected'
await updateExplorer(renderer)
expect(client.sendRequest).toHaveBeenCalledTimes(1)
expect(renderedText(renderer)).toContain('src')
expect(renderedText(renderer)).toContain('README.md')
expect(renderedText(renderer)).not.toContain('Waiting for desktop...')
})
it('refreshes the root in the background after reconnect without blanking the tree', async () => {
const client = createMockClient({
'': [entry('src', true), entry('README.md')]
})
mockTransport.client = client
const renderer = await renderExplorer()
expect(renderedText(renderer)).toContain('README.md')
mockTransport.client = null
mockTransport.connectionState = 'disconnected'
await updateExplorer(renderer)
let resolveReload: ((response: RpcResponse) => void) | undefined
const reconnectedClient: MockClient = {
sendRequest: vi.fn(
() =>
new Promise<RpcResponse>((resolve) => {
resolveReload = resolve
})
)
}
mockTransport.client = reconnectedClient
mockTransport.connectionState = 'connected'
await updateExplorer(renderer)
expect(reconnectedClient.sendRequest).toHaveBeenCalledTimes(1)
expect(renderedText(renderer)).toContain('src')
expect(renderedText(renderer)).toContain('README.md')
await act(async () => {
resolveReload?.(ok([entry('src', true), entry('README.md'), entry('CHANGELOG.md')]))
})
expect(renderedText(renderer)).toContain('CHANGELOG.md')
})
it('keeps the cached tree when a post-reconnect root refresh fails', async () => {
const client = createMockClient({
'': [entry('src', true), entry('README.md')]
})
mockTransport.client = client
const renderer = await renderExplorer()
expect(renderedText(renderer)).toContain('README.md')
mockTransport.client = null
mockTransport.connectionState = 'disconnected'
await updateExplorer(renderer)
const failingClient: MockClient = {
sendRequest: vi.fn(async () => {
throw new Error('refresh failed')
})
}
mockTransport.client = failingClient
mockTransport.connectionState = 'connected'
await updateExplorer(renderer)
expect(failingClient.sendRequest).toHaveBeenCalledTimes(1)
expect(renderedText(renderer)).toContain('src')
expect(renderedText(renderer)).toContain('README.md')
expect(renderedText(renderer)).not.toContain('refresh failed')
})
it('falls back to the capped files.list against desktops without files.readDir', async () => {
const legacyClient: MockClient = {
sendRequest: vi.fn(async (method: string): Promise<RpcResponse> => {
if (method === 'files.readDir') {
return {
id: 'response-id',
ok: false,
error: {
code: 'forbidden',
message: "Method 'files.readDir' is not available to mobile clients"
},
_meta: { runtimeId: 'runtime-id' }
}
}
return {
id: 'response-id',
ok: true,
result: {
files: [
{ relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' },
{ relativePath: 'README.md', basename: 'README.md', kind: 'text' }
],
totalCount: 2,
truncated: false
},
_meta: { runtimeId: 'runtime-id' }
}
})
}
mockTransport.client = legacyClient
const renderer = await renderExplorer()
expect(legacyClient.sendRequest).toHaveBeenCalledWith('files.list', {
worktree: 'id:worktree-a'
})
expect(renderedText(renderer)).toContain('src')
expect(renderedText(renderer)).toContain('README.md')
await pressByLabel(renderer, 'Open folder src')
expect(renderedText(renderer)).toContain('app.ts')
// Every directory comes from the synthesized cache: one readDir attempt
// plus one files.list call total, no per-directory RPCs afterwards.
expect(legacyClient.sendRequest).toHaveBeenCalledTimes(2)
})
it('surfaces the legacy cap note when the files.list fallback is truncated', async () => {
const legacyClient: MockClient = {
sendRequest: vi.fn(async (method: string): Promise<RpcResponse> => {
if (method === 'files.readDir') {
return {
id: 'response-id',
ok: false,
error: { code: 'method_not_found', message: 'Unknown method' },
_meta: { runtimeId: 'runtime-id' }
}
}
return {
id: 'response-id',
ok: true,
result: {
files: [{ relativePath: 'README.md', basename: 'README.md', kind: 'text' }],
totalCount: 6000,
truncated: true
},
_meta: { runtimeId: 'runtime-id' }
}
})
}
mockTransport.client = legacyClient
const renderer = await renderExplorer()
expect(renderedText(renderer)).toContain('README.md')
expect(renderedText(renderer)).toContain('Showing first 5000')
})
it('reports the files.list failure when the fallback itself fails', async () => {
const legacyClient: MockClient = {
sendRequest: vi.fn(async (method: string): Promise<RpcResponse> => {
if (method === 'files.readDir') {
return {
id: 'response-id',
ok: false,
error: {
code: 'forbidden',
message: "Method 'files.readDir' is not available to mobile clients"
},
_meta: { runtimeId: 'runtime-id' }
}
}
return {
id: 'response-id',
ok: false,
error: { code: 'internal', message: 'legacy list failed' },
_meta: { runtimeId: 'runtime-id' }
}
})
}
mockTransport.client = legacyClient
const renderer = await renderExplorer()
expect(renderedText(renderer)).toContain('legacy list failed')
expect(renderedText(renderer)).not.toContain('not available to mobile clients')
})
})
+230 -123
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
FlatList,
@@ -9,34 +9,33 @@ import {
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import {
ChevronDown,
ChevronLeft,
ChevronRight,
File,
FileText,
Folder,
Image as ImageIcon,
X
} from 'lucide-react-native'
import { ChevronLeft, X } from 'lucide-react-native'
import { useHostClient, useForceReconnect } from '../transport/client-context'
import { getWorktreeLabel } from '../session/worktree-label'
import {
buildTree,
flattenTree,
isMarkdownPath,
type FilesListResult,
type MobileFileEntry,
type TreeNode
flattenDirectoryCache,
getDirectoryCacheState,
type DirectoryCache,
type FileExplorerRow,
type MobileDirEntry
} from './file-tree'
import type { RpcSuccess } from '../transport/types'
import { triggerSelection } from '../platform/haptics'
import { colors, spacing } from '../theme/mobile-theme'
import { fileExplorerStyles as styles } from './mobile-file-explorer-styles'
import { colors } from '../theme/mobile-theme'
import {
canPreviewMobileFileRow,
navigateToMobileFilePreview
} from './mobile-file-preview-navigation'
beginDirectoryLoad,
createDirectoryLoadRevisions,
isCurrentDirectoryLoad,
resetDirectoryLoadRevisions,
type DirectoryLoadRevisions
} from './directory-load-revisions'
import {
directoryCacheFromFileList,
isMobileMethodUnavailableError,
type LegacyFilesListResult
} from './file-list-fallback'
import { fileExplorerStyles as styles } from './mobile-file-explorer-styles'
import { MobileFileExplorerRow } from './mobile-file-explorer-row'
import { navigateToMobileFilePreview } from './mobile-file-preview-navigation'
export function MobileFileExplorerPanel(props: {
hostId: string
@@ -49,58 +48,217 @@ export function MobileFileExplorerPanel(props: {
const router = useRouter()
const { client, state: connState } = useHostClient(hostId)
const forceReconnect = useForceReconnect()
const [files, setFiles] = useState<MobileFileEntry[]>([])
const scopeRef = useRef('')
const scope = `${hostId}:${worktreeId}`
scopeRef.current = scope
const directoryLoadRevisionsRef = useRef<DirectoryLoadRevisions>(createDirectoryLoadRevisions())
const pendingDirectoryRetriesRef = useRef<Set<string>>(new Set())
const directoryCacheRef = useRef<DirectoryCache>({})
const [directoryCache, setDirectoryCache] = useState<DirectoryCache>({})
const [expanded, setExpanded] = useState<Set<string>>(() => new Set())
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [truncated, setTruncated] = useState(false)
const [legacyListTruncated, setLegacyListTruncated] = useState(false)
const worktreeLabel = getWorktreeLabel(name, worktreeId)
const loadFiles = useCallback(async () => {
if (!client || connState !== 'connected') {
setLoading(false)
setError(connState === 'connected' ? 'Connecting to desktop...' : 'Waiting for desktop...')
return
}
setLoading(true)
setError(null)
try {
const response = await client.sendRequest('files.list', { worktree: `id:${worktreeId}` })
if (!response.ok) {
throw new Error(response.error?.message || 'Unable to load files')
const loadDirectory = useCallback(
async (relativePath: string) => {
const scope = scopeRef.current
const loadToken = beginDirectoryLoad(directoryLoadRevisionsRef.current, scope, relativePath)
const rootLoad = relativePath === ''
if (!client || connState !== 'connected') {
const message =
connState === 'connected' ? 'Connecting to desktop...' : 'Waiting for desktop...'
if (rootLoad) {
const hasLoadedRoot =
(getDirectoryCacheState(directoryCacheRef.current, '')?.entries.length ?? 0) > 0
setLoading(false)
// Why: transient reconnects should not blank an already browsable tree.
setError(hasLoadedRoot ? null : message)
} else {
setDirectoryCache((prev) => ({
...prev,
[relativePath]: {
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
error: message
}
}))
}
return
}
const result = (response as RpcSuccess).result as FilesListResult
setFiles(result.files)
setTruncated(result.truncated)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to load files')
} finally {
setLoading(false)
}
}, [client, connState, worktreeId])
const hadLoadedRoot =
rootLoad && (getDirectoryCacheState(directoryCacheRef.current, '')?.entries.length ?? 0) > 0
if (rootLoad) {
// Why: a reconnect refresh must not blank an already browsable tree —
// the full-screen spinner unmounts the list and resets scroll.
if (!hadLoadedRoot) {
setLoading(true)
}
setError(null)
}
setDirectoryCache((prev) => ({
...prev,
[relativePath]: {
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
loading: true
}
}))
try {
const response = await client.sendRequest('files.readDir', {
worktree: `id:${worktreeId}`,
relativePath
})
if (!response.ok) {
// Why: desktops that predate the files.readDir mobile allowlist
// entry still serve the capped files.list; fall back so the Files
// tab keeps working until the desktop updates.
if (
rootLoad &&
isMobileMethodUnavailableError(response.error?.code, response.error?.message)
) {
const legacy = await client.sendRequest('files.list', {
worktree: `id:${worktreeId}`
})
if (legacy.ok) {
if (
!isCurrentDirectoryLoad(
directoryLoadRevisionsRef.current,
scopeRef.current,
loadToken
)
) {
return
}
const legacyResult = (legacy as RpcSuccess).result as LegacyFilesListResult
setDirectoryCache(directoryCacheFromFileList(legacyResult.files))
// Why: the capped list silently omits files past the cap — keep
// the legacy explorer's "Showing first 5000" note.
setLegacyListTruncated(legacyResult.truncated)
return
}
throw new Error(
legacy.error?.message || response.error?.message || 'Unable to load files'
)
}
throw new Error(response.error?.message || 'Unable to load files')
}
if (
!isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken)
) {
return
}
const entries = (response as RpcSuccess).result as MobileDirEntry[]
if (rootLoad) {
setLegacyListTruncated(false)
}
setDirectoryCache((prev) => ({
...prev,
[relativePath]: { entries }
}))
} catch (err) {
if (
!isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken)
) {
return
}
const message = err instanceof Error ? err.message : 'Unable to load files'
if (rootLoad) {
// Why: a failed background refresh keeps the cached tree browsable;
// only a cold load surfaces the full-screen error.
setError(hadLoadedRoot ? null : message)
} else {
setDirectoryCache((prev) => ({
...prev,
[relativePath]: {
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
error: message
}
}))
}
} finally {
if (
rootLoad &&
isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken)
) {
setLoading(false)
}
}
},
[client, connState, worktreeId]
)
useEffect(() => {
void loadFiles()
}, [loadFiles])
scopeRef.current = scope
resetDirectoryLoadRevisions(directoryLoadRevisionsRef.current)
pendingDirectoryRetriesRef.current.clear()
directoryCacheRef.current = {}
setDirectoryCache({})
setExpanded(new Set())
setLoading(true)
setError(null)
setLegacyListTruncated(false)
}, [scope])
const rows = useMemo(() => flattenTree(buildTree(files), expanded), [expanded, files])
useEffect(() => {
directoryCacheRef.current = directoryCache
}, [directoryCache])
const toggleDirectory = useCallback((relativePath: string) => {
triggerSelection()
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(relativePath)) {
next.delete(relativePath)
} else {
next.add(relativePath)
useEffect(() => {
void loadDirectory('')
}, [hostId, loadDirectory])
useEffect(() => {
if (connState !== 'connected' || pendingDirectoryRetriesRef.current.size === 0) {
return
}
const pending = [...pendingDirectoryRetriesRef.current]
pendingDirectoryRetriesRef.current.clear()
for (const relativePath of pending) {
void loadDirectory(relativePath)
}
}, [connState, loadDirectory])
const rows = useMemo(
() => flattenDirectoryCache(directoryCache, expanded),
[directoryCache, expanded]
)
const toggleDirectory = useCallback(
(relativePath: string) => {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(relativePath)) {
next.delete(relativePath)
} else {
next.add(relativePath)
}
return next
})
const state = getDirectoryCacheState(directoryCache, relativePath)
if (!expanded.has(relativePath) && !state?.loading && (!state?.entries || state.error)) {
void loadDirectory(relativePath)
}
return next
})
}, [])
},
[directoryCache, expanded, loadDirectory]
)
const retryDirectory = useCallback(
(relativePath: string) => {
if (connState !== 'connected' && hostId) {
pendingDirectoryRetriesRef.current.add(relativePath)
void forceReconnect(hostId)
return
}
void loadDirectory(relativePath)
},
[connState, forceReconnect, hostId, loadDirectory]
)
const previewFile = useCallback(
(relativePath: string, displayName: string) => {
triggerSelection()
navigateToMobileFilePreview(
router,
{
@@ -116,66 +274,15 @@ export function MobileFileExplorerPanel(props: {
[embedded, hostId, name, onRequestClose, router, worktreeId]
)
const renderItem: ListRenderItem<TreeNode> = ({ item }) => {
const isDirectory = item.kind === 'directory'
const isExpanded = expanded.has(item.relativePath)
// Images render in the mobile viewer (via files.readPreview), so a binary
// image is openable; only non-previewable binaries are unavailable.
const previewable =
item.kind !== 'directory' &&
canPreviewMobileFileRow({ kind: item.kind, relativePath: item.relativePath })
const isImage = item.kind === 'binary' && previewable
const disabled = item.kind === 'binary' && !previewable
const markdown = item.kind === 'text' && isMarkdownPath(item.relativePath)
const renderItem: ListRenderItem<FileExplorerRow> = ({ item }) => {
return (
<Pressable
style={({ pressed }) => [
styles.row,
{ paddingLeft: spacing.lg + item.depth * 18 },
pressed && !disabled && styles.rowPressed,
disabled && styles.rowDisabled
]}
disabled={disabled}
onPress={() => {
if (isDirectory) {
toggleDirectory(item.relativePath)
} else if (!disabled) {
previewFile(item.relativePath, item.name)
}
}}
accessibilityLabel={
isDirectory
? `Open folder ${item.name}`
: disabled
? `${item.name} unavailable on mobile`
: `Preview file ${item.name}`
}
>
{isDirectory ? (
isExpanded ? (
<ChevronDown size={16} color={colors.textSecondary} />
) : (
<ChevronRight size={16} color={colors.textSecondary} />
)
) : (
<View style={styles.chevronSpacer} />
)}
{isDirectory ? (
<Folder size={17} color={colors.textSecondary} />
) : markdown ? (
<FileText size={17} color={disabled ? colors.textMuted : colors.textSecondary} />
) : isImage ? (
<ImageIcon size={17} color={colors.textSecondary} />
) : (
<File size={17} color={disabled ? colors.textMuted : colors.textSecondary} />
)}
<View style={styles.rowTextBlock}>
<Text style={[styles.rowTitle, disabled && styles.rowTitleDisabled]} numberOfLines={1}>
{item.name}
</Text>
{disabled ? <Text style={styles.rowMeta}>Unavailable on mobile</Text> : null}
</View>
</Pressable>
<MobileFileExplorerRow
item={item}
expanded={expanded}
onPreviewFile={previewFile}
onRetryDirectory={retryDirectory}
onToggleDirectory={toggleDirectory}
/>
)
}
@@ -206,7 +313,7 @@ export function MobileFileExplorerPanel(props: {
</Text>
<Text style={styles.meta} numberOfLines={1}>
{worktreeLabel}
{truncated ? ' - Showing first 5000' : ''}
{legacyListTruncated ? ' - Showing first 5000' : ''}
</Text>
</View>
</View>
@@ -220,12 +327,12 @@ export function MobileFileExplorerPanel(props: {
<View style={styles.state}>
<Text style={styles.errorText}>{error}</Text>
{/* Why: while disconnected, re-sending the request is useless — revive
the parked transport instead (issue #5049); loadFiles re-runs via
the parked transport instead (issue #5049); loadDirectory re-runs via
its effect once the new client connects. */}
<Pressable
style={styles.retryButton}
onPress={() =>
connState !== 'connected' && hostId ? void forceReconnect(hostId) : void loadFiles()
connState !== 'connected' && hostId ? void forceReconnect(hostId) : void loadDirectory('')
}
>
<Text style={styles.retryText}>Retry</Text>
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import {
beginDirectoryLoad,
createDirectoryLoadRevisions,
isCurrentDirectoryLoad,
resetDirectoryLoadRevisions,
type DirectoryLoadRevisions
} from './directory-load-revisions'
describe('directory-load-revisions', () => {
it('ignores stale duplicate loads for the same directory and scope', () => {
const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions()
const older = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src')
const newer = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src')
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', newer)).toBe(true)
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', older)).toBe(false)
})
it('ignores loads from an old host/worktree scope', () => {
const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions()
const oldScopeLoad = beginDirectoryLoad(revisions, 'host-a:worktree-a', '')
expect(isCurrentDirectoryLoad(revisions, 'host-b:worktree-b', oldScopeLoad)).toBe(false)
})
it('ignores loads from an old reset generation in the same scope', () => {
const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions()
const oldLoad = beginDirectoryLoad(revisions, 'host-a:worktree-a', '')
resetDirectoryLoadRevisions(revisions)
const newLoad = beginDirectoryLoad(revisions, 'host-a:worktree-a', '')
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', newLoad)).toBe(true)
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', oldLoad)).toBe(false)
})
it('tracks directory names that overlap object prototype keys', () => {
const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions()
const load = beginDirectoryLoad(revisions, 'host-a:worktree-a', '__proto__')
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', load)).toBe(true)
})
})
@@ -0,0 +1,42 @@
export type DirectoryLoadRevisions = {
generation: number
revisionsByPath: Map<string, number>
}
export type DirectoryLoadToken = {
generation: number
relativePath: string
revision: number
scope: string
}
export function createDirectoryLoadRevisions(): DirectoryLoadRevisions {
return { generation: 0, revisionsByPath: new Map() }
}
export function resetDirectoryLoadRevisions(revisions: DirectoryLoadRevisions): void {
revisions.generation += 1
revisions.revisionsByPath.clear()
}
export function beginDirectoryLoad(
revisions: DirectoryLoadRevisions,
scope: string,
relativePath: string
): DirectoryLoadToken {
const revision = (revisions.revisionsByPath.get(relativePath) ?? 0) + 1
revisions.revisionsByPath.set(relativePath, revision)
return { generation: revisions.generation, relativePath, revision, scope }
}
export function isCurrentDirectoryLoad(
revisions: DirectoryLoadRevisions,
currentScope: string,
token: DirectoryLoadToken
): boolean {
return (
currentScope === token.scope &&
revisions.generation === token.generation &&
revisions.revisionsByPath.get(token.relativePath) === token.revision
)
}
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { directoryCacheFromFileList, isMobileMethodUnavailableError } from './file-list-fallback'
import { getDirectoryCacheState } from './file-tree'
describe('isMobileMethodUnavailableError', () => {
it('detects old-desktop allowlist and missing-method failures', () => {
expect(isMobileMethodUnavailableError('forbidden', undefined)).toBe(true)
expect(isMobileMethodUnavailableError('method_not_found', undefined)).toBe(true)
expect(
isMobileMethodUnavailableError(
'some_code',
"Method 'files.readDir' is not available to mobile clients"
)
).toBe(true)
expect(isMobileMethodUnavailableError('internal', 'boom')).toBe(false)
expect(isMobileMethodUnavailableError(undefined, undefined)).toBe(false)
})
})
describe('directoryCacheFromFileList', () => {
it('synthesizes every ancestor directory from flat paths', () => {
const cache = directoryCacheFromFileList([
{ relativePath: 'src/lib/util.ts', basename: 'util.ts', kind: 'text' },
{ relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' },
{ relativePath: 'README.md', basename: 'README.md', kind: 'text' }
])
expect(cache['']?.entries).toEqual(
expect.arrayContaining([
{ name: 'src', isDirectory: true },
{ name: 'README.md', isDirectory: false }
])
)
expect(cache['src']?.entries).toEqual(
expect.arrayContaining([
{ name: 'lib', isDirectory: true },
{ name: 'app.ts', isDirectory: false }
])
)
expect(cache['src/lib']?.entries).toEqual([{ name: 'util.ts', isDirectory: false }])
})
it('keeps a name a directory when it appears as both file and dir prefix', () => {
const cache = directoryCacheFromFileList([
{ relativePath: 'src', basename: 'src', kind: 'text' },
{ relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' }
])
expect(cache['']?.entries).toEqual([{ name: 'src', isDirectory: true }])
})
it('returns an empty root for an empty list', () => {
const cache = directoryCacheFromFileList([])
expect(cache['']?.entries).toEqual([])
})
it('stores a __proto__ directory as an own key instead of mutating the prototype', () => {
const cache = directoryCacheFromFileList([
{ relativePath: '__proto__/pollute.js', basename: 'pollute.js', kind: 'text' }
])
expect(Object.getPrototypeOf(cache)).toBe(Object.prototype)
expect(cache['']?.entries).toEqual([{ name: '__proto__', isDirectory: true }])
expect(getDirectoryCacheState(cache, '__proto__')?.entries).toEqual([
{ name: 'pollute.js', isDirectory: false }
])
})
})
+73
View File
@@ -0,0 +1,73 @@
// Fallback for desktops that predate files.readDir in the mobile RPC
// allowlist: synthesize the lazy directory cache from the flat, capped
// files.list result so the Files tab stays browsable against old desktops.
import type { DirectoryCache, MobileDirEntry } from './file-tree'
export type LegacyMobileFileEntry = {
relativePath: string
basename: string
kind: 'text' | 'binary'
}
export type LegacyFilesListResult = {
files: LegacyMobileFileEntry[]
totalCount: number
truncated: boolean
}
// Same detection shape as isMobileGitUnavailable in mobile-git-status.ts:
// 'forbidden' = method exists but is not mobile-allowlisted on the old
// desktop; 'method_not_found' = desktop predates the method entirely.
export function isMobileMethodUnavailableError(
code: string | undefined,
message: string | undefined
): boolean {
return (
code === 'forbidden' ||
code === 'method_not_found' ||
message?.includes('not available to mobile clients') === true
)
}
export function directoryCacheFromFileList(files: LegacyMobileFileEntry[]): DirectoryCache {
const childrenByDir = new Map<string, Map<string, boolean>>()
const ensureDir = (path: string): Map<string, boolean> => {
let children = childrenByDir.get(path)
if (!children) {
children = new Map()
childrenByDir.set(path, children)
}
return children
}
ensureDir('')
for (const file of files) {
const parts = file.relativePath.split('/').filter(Boolean)
let parentPath = ''
parts.forEach((name, index) => {
const isDirectory = index < parts.length - 1
const children = ensureDir(parentPath)
children.set(name, children.get(name) === true || isDirectory)
parentPath = parentPath ? `${parentPath}/${name}` : name
if (isDirectory) {
ensureDir(parentPath)
}
})
}
// Why: plain `cache[path] = ...` with a '__proto__' path segment mutates the
// object's prototype instead of storing the directory; fromEntries always
// creates own keys.
return Object.fromEntries(
Array.from(childrenByDir, ([path, children]) => [
path,
{
entries: Array.from(
children,
([name, isDirectory]): MobileDirEntry => ({
name,
isDirectory
})
)
}
])
)
}
+96 -19
View File
@@ -1,32 +1,109 @@
import { describe, expect, it } from 'vitest'
import { buildTree, flattenTree, isMarkdownPath, type MobileFileEntry } from './file-tree'
import {
flattenDirectoryCache,
getMobileFileKind,
isMarkdownPath,
shouldIncludeMobileFileExplorerEntry,
type DirectoryCache,
type MobileDirEntry
} from './file-tree'
function entry(relativePath: string, kind: 'text' | 'binary' = 'text'): MobileFileEntry {
return { relativePath, basename: relativePath.split('/').pop() ?? relativePath, kind }
function entry(name: string, isDirectory = false, isSymlink = false): MobileDirEntry {
return { name, isDirectory, isSymlink }
}
describe('file-tree', () => {
it('nests files under their directories', () => {
const root = buildTree([entry('src/app.ts'), entry('src/lib/util.ts'), entry('readme.md')])
expect(root.files.map((f) => f.relativePath)).toEqual(['readme.md'])
expect(root.directories.get('src')?.directories.get('lib')?.files[0]?.relativePath).toBe(
'src/lib/util.ts'
)
it('flattens cached directories lazily with directories before files', () => {
const cache: DirectoryCache = {
'': { entries: [entry('zeta.txt'), entry('src', true), entry('readme.md')] },
src: { entries: [entry('app.ts'), entry('lib', true)] },
'src/lib': { entries: [entry('util.ts')] }
}
const collapsed = flattenDirectoryCache(cache, new Set())
expect(collapsed.map((row) => row.id)).toEqual(['dir:src', 'file:readme.md', 'file:zeta.txt'])
const expanded = flattenDirectoryCache(cache, new Set(['src', 'src/lib']))
expect(expanded.map((row) => row.id)).toEqual([
'dir:src',
'dir:src/lib',
'file:src/lib/util.ts',
'file:src/app.ts',
'file:readme.md',
'file:zeta.txt'
])
})
it('flattens with directories before files and only expands open dirs', () => {
const root = buildTree([entry('src/app.ts'), entry('zeta.txt')])
const collapsed = flattenTree(root, new Set())
expect(collapsed.map((r) => r.id)).toEqual(['dir:src', 'file:zeta.txt'])
it('mirrors desktop default browse exclusions while keeping dotfiles visible', () => {
const cache: DirectoryCache = {
'': {
entries: [
entry('.git', true),
entry('.env'),
entry('node_modules', true),
entry('src', true)
]
}
}
const expanded = flattenTree(root, new Set(['src']))
expect(expanded.map((r) => r.id)).toEqual(['dir:src', 'file:src/app.ts', 'file:zeta.txt'])
expect(flattenDirectoryCache(cache, new Set()).map((row) => row.id)).toEqual([
'dir:src',
'file:.env'
])
expect(shouldIncludeMobileFileExplorerEntry(entry('.config', true))).toBe(true)
})
it('preserves the binary kind on flattened rows', () => {
const root = buildTree([entry('assets/logo.png', 'binary')])
const rows = flattenTree(root, new Set(['assets']))
expect(rows.find((r) => r.id === 'file:assets/logo.png')?.kind).toBe('binary')
it('renders inline loading and error rows under expanded directories', () => {
const cache: DirectoryCache = {
'': { entries: [entry('loading-dir', true), entry('error-dir', true)] },
'loading-dir': { entries: [], loading: true },
'error-dir': { entries: [], error: 'permission denied' }
}
const rows = flattenDirectoryCache(cache, new Set(['loading-dir', 'error-dir']))
expect(rows.map((row) => row.id)).toEqual([
'dir:error-dir',
'error:error-dir',
'dir:loading-dir',
'loading:loading-dir'
])
expect(rows.find((row) => row.id === 'error:error-dir')).toMatchObject({
kind: 'error',
message: 'permission denied'
})
})
it('preserves symlink metadata for user-initiated activation', () => {
const cache: DirectoryCache = {
'': { entries: [entry('linked-docs', false, true)] }
}
expect(flattenDirectoryCache(cache, new Set())).toEqual([
{
id: 'file:linked-docs',
name: 'linked-docs',
relativePath: 'linked-docs',
depth: 0,
kind: 'text',
isSymlink: true
}
])
})
it('does not treat inherited object keys as loaded directories', () => {
const cache: DirectoryCache = {
'': { entries: [entry('constructor', true), entry('__proto__', true)] },
['__proto__']: { entries: [entry('file.txt')] }
}
expect(
flattenDirectoryCache(cache, new Set(['constructor', '__proto__'])).map((row) => row.id)
).toEqual(['dir:__proto__', 'file:__proto__/file.txt', 'dir:constructor'])
})
it('classifies binary file paths for existing mobile preview behavior', () => {
expect(getMobileFileKind('assets/logo.png')).toBe('binary')
expect(getMobileFileKind('src/app.ts')).toBe('text')
})
it('detects markdown paths', () => {
+130 -69
View File
@@ -1,91 +1,152 @@
// Pure tree model for the mobile file explorer: turns the flat files.list
// result into a nested directory structure and flattens it into renderable
// rows. Kept out of the screen component so the screen stays under its line cap.
// Pure tree projection for the mobile file explorer. Mobile mirrors desktop
// browse semantics by flattening cached files.readDir results as folders open.
export type MobileFileEntry = {
relativePath: string
basename: string
kind: 'text' | 'binary'
export type MobileDirEntry = {
name: string
isDirectory: boolean
isSymlink?: boolean
}
export type FilesListResult = {
files: MobileFileEntry[]
totalCount: number
truncated: boolean
}
export type MobileFileKind = 'text' | 'binary'
export type TreeNode = {
id: string
name: string
relativePath: string
depth: number
kind: 'directory' | 'text' | 'binary'
kind: 'directory' | MobileFileKind
isSymlink?: boolean
}
export type DirectoryNode = {
name: string
export type DirectoryState = {
entries: MobileDirEntry[]
loading?: boolean
error?: string
}
export type DirectoryCache = Record<string, DirectoryState | undefined>
export type InlineStatusNode = {
id: string
relativePath: string
directories: Map<string, DirectoryNode>
files: MobileFileEntry[]
depth: number
kind: 'loading' | 'error'
message?: string
}
function createDirectoryNode(name: string, relativePath: string): DirectoryNode {
return { name, relativePath, directories: new Map(), files: [] }
}
export type FileExplorerRow = TreeNode | InlineStatusNode
export function buildTree(files: MobileFileEntry[]): DirectoryNode {
const root = createDirectoryNode('', '')
for (const file of files) {
const parts = file.relativePath.split('/').filter(Boolean)
let current = root
for (let index = 0; index < parts.length - 1; index += 1) {
const name = parts[index]!
const relativePath = parts.slice(0, index + 1).join('/')
let child = current.directories.get(name)
if (!child) {
child = createDirectoryNode(name, relativePath)
current.directories.set(name, child)
}
current = child
}
current.files.push(file)
}
return root
}
const DESKTOP_EXCLUDED_NAMES = new Set(['.git', 'node_modules'])
const BINARY_EXTENSIONS = new Set([
'.avif',
'.bmp',
'.gif',
'.heic',
'.ico',
'.jpeg',
'.jpg',
'.mov',
'.mp3',
'.mp4',
'.pdf',
'.png',
'.webp',
'.zip'
])
export function flattenTree(root: DirectoryNode, expanded: ReadonlySet<string>): TreeNode[] {
const rows: TreeNode[] = []
const visit = (directory: DirectoryNode, depth: number): void => {
const dirs = Array.from(directory.directories.values()).sort((a, b) =>
a.name.localeCompare(b.name)
)
for (const child of dirs) {
rows.push({
id: `dir:${child.relativePath}`,
name: child.name,
relativePath: child.relativePath,
depth,
kind: 'directory'
})
if (expanded.has(child.relativePath)) {
visit(child, depth + 1)
}
}
const files = [...directory.files].sort((a, b) => a.basename.localeCompare(b.basename))
for (const file of files) {
rows.push({
id: `file:${file.relativePath}`,
name: file.basename,
relativePath: file.relativePath,
depth,
kind: file.kind
})
}
}
visit(root, 0)
export function flattenDirectoryCache(
cache: DirectoryCache,
expanded: ReadonlySet<string>
): FileExplorerRow[] {
const rows: FileExplorerRow[] = []
visitDirectory('', 0, cache, expanded, rows)
return rows
}
function visitDirectory(
relativePath: string,
depth: number,
cache: DirectoryCache,
expanded: ReadonlySet<string>,
rows: FileExplorerRow[]
): void {
const state = getDirectoryCacheState(cache, relativePath)
const entries = state?.entries ?? []
const visibleEntries = entries
.filter(shouldIncludeMobileFileExplorerEntry)
.sort(compareDirectoryEntries)
for (const entry of visibleEntries) {
const childPath = joinRelativePath(relativePath, entry.name)
rows.push(toTreeNode(entry, childPath, depth))
if (entry.isDirectory && expanded.has(childPath)) {
const childState = getDirectoryCacheState(cache, childPath)
if (childState?.loading) {
rows.push({
id: `loading:${childPath}`,
relativePath: childPath,
depth: depth + 1,
kind: 'loading'
})
} else if (childState?.error) {
rows.push({
id: `error:${childPath}`,
relativePath: childPath,
depth: depth + 1,
kind: 'error',
message: childState.error
})
} else {
visitDirectory(childPath, depth + 1, cache, expanded, rows)
}
}
}
}
function toTreeNode(entry: MobileDirEntry, relativePath: string, depth: number): TreeNode {
return {
id: `${entry.isDirectory ? 'dir' : 'file'}:${relativePath}`,
name: entry.name,
relativePath,
depth,
kind: entry.isDirectory ? 'directory' : getMobileFileKind(relativePath),
isSymlink: entry.isSymlink
}
}
function compareDirectoryEntries(a: MobileDirEntry, b: MobileDirEntry): number {
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? -1 : 1
}
return a.name.localeCompare(b.name)
}
export function shouldIncludeMobileFileExplorerEntry(entry: MobileDirEntry): boolean {
return !DESKTOP_EXCLUDED_NAMES.has(entry.name)
}
export function getDirectoryCacheState(
cache: DirectoryCache,
relativePath: string
): DirectoryState | undefined {
// Why: repository paths are arbitrary object keys; inherited keys like
// "constructor" must not masquerade as loaded directory state.
return Object.prototype.hasOwnProperty.call(cache, relativePath) ? cache[relativePath] : undefined
}
export function joinRelativePath(parentPath: string, name: string): string {
return parentPath ? `${parentPath}/${name}` : name
}
export function getMobileFileKind(relativePath: string): MobileFileKind {
const basename = relativePath.split('/').pop() ?? relativePath
const dotIndex = basename.lastIndexOf('.')
if (dotIndex <= 0) {
return 'text'
}
return BINARY_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase()) ? 'binary' : 'text'
}
export function isMarkdownPath(relativePath: string): boolean {
return /\.(md|mdx|markdown)$/i.test(relativePath)
}
@@ -0,0 +1,145 @@
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import {
ChevronDown,
ChevronRight,
File,
FileText,
Folder,
Image as ImageIcon
} from 'lucide-react-native'
import { triggerSelection } from '../platform/haptics'
import { colors, spacing } from '../theme/mobile-theme'
import { type FileExplorerRow, isMarkdownPath, type TreeNode } from './file-tree'
import { fileExplorerStyles as styles } from './mobile-file-explorer-styles'
import { canPreviewMobileFileRow } from './mobile-file-preview-navigation'
type Props = {
item: FileExplorerRow
expanded: ReadonlySet<string>
onPreviewFile: (relativePath: string, displayName: string) => void
onRetryDirectory: (relativePath: string) => void
onToggleDirectory: (relativePath: string) => void
}
export function MobileFileExplorerRow(props: Props) {
const { item, expanded, onPreviewFile, onRetryDirectory, onToggleDirectory } = props
if (item.kind === 'loading') {
return (
<View style={[styles.inlineStatusRow, { paddingLeft: spacing.lg + item.depth * 18 }]}>
<View style={styles.chevronSpacer} />
<ActivityIndicator size="small" color={colors.textSecondary} />
<Text style={styles.inlineStatusText}>Loading...</Text>
</View>
)
}
if (item.kind === 'error') {
return (
<View style={[styles.inlineStatusRow, { paddingLeft: spacing.lg + item.depth * 18 }]}>
<View style={styles.chevronSpacer} />
<Text style={styles.inlineErrorText} numberOfLines={1}>
{item.message || 'Unable to load folder'}
</Text>
<Pressable
style={({ pressed }) => [styles.inlineRetryButton, pressed && styles.rowPressed]}
onPress={() => {
triggerSelection()
onRetryDirectory(item.relativePath)
}}
accessibilityLabel={`Retry loading ${item.relativePath}`}
>
<Text style={styles.inlineRetryText}>Retry</Text>
</Pressable>
</View>
)
}
if (isTreeNode(item)) {
return (
<TreeRow
item={item}
expanded={expanded}
onPreviewFile={onPreviewFile}
onToggleDirectory={onToggleDirectory}
/>
)
}
return null
}
function isTreeNode(item: FileExplorerRow): item is TreeNode {
return item.kind === 'directory' || item.kind === 'text' || item.kind === 'binary'
}
function TreeRow(props: {
item: TreeNode
expanded: ReadonlySet<string>
onPreviewFile: (relativePath: string, displayName: string) => void
onToggleDirectory: (relativePath: string) => void
}) {
const { item, expanded, onPreviewFile, onToggleDirectory } = props
const isDirectory = item.kind === 'directory'
const isExpanded = expanded.has(item.relativePath)
// Images render in the mobile viewer (via files.readPreview), so a binary
// image is openable; only non-previewable binaries are unavailable.
const previewable =
item.kind !== 'directory' &&
canPreviewMobileFileRow({ kind: item.kind, relativePath: item.relativePath })
const isImage = item.kind === 'binary' && previewable
const disabled = item.kind === 'binary' && !previewable
const markdown = item.kind === 'text' && isMarkdownPath(item.relativePath)
return (
<Pressable
style={({ pressed }) => [
styles.row,
{ paddingLeft: spacing.lg + item.depth * 18 },
pressed && !disabled && styles.rowPressed,
disabled && styles.rowDisabled
]}
disabled={disabled}
onPress={() => {
triggerSelection()
if (isDirectory) {
onToggleDirectory(item.relativePath)
} else if (!disabled) {
onPreviewFile(item.relativePath, item.name)
}
}}
accessibilityLabel={
isDirectory
? `Open folder ${item.name}`
: disabled
? `${item.name} unavailable on mobile`
: `Preview file ${item.name}`
}
>
{isDirectory ? (
isExpanded ? (
<ChevronDown size={16} color={colors.textSecondary} />
) : (
<ChevronRight size={16} color={colors.textSecondary} />
)
) : (
<View style={styles.chevronSpacer} />
)}
{isDirectory ? (
<Folder size={17} color={colors.textSecondary} />
) : markdown ? (
<FileText size={17} color={disabled ? colors.textMuted : colors.textSecondary} />
) : isImage ? (
<ImageIcon size={17} color={colors.textSecondary} />
) : (
<File size={17} color={disabled ? colors.textMuted : colors.textSecondary} />
)}
<View style={styles.rowTextBlock}>
<Text style={[styles.rowTitle, disabled && styles.rowTitleDisabled]} numberOfLines={1}>
{item.name}
</Text>
{disabled ? <Text style={styles.rowMeta}>Unavailable on mobile</Text> : null}
</View>
</Pressable>
)
}
@@ -78,6 +78,37 @@ export const fileExplorerStyles = StyleSheet.create({
color: colors.textMuted,
fontSize: 11
},
inlineStatusRow: {
minHeight: 36,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingRight: spacing.md
},
inlineStatusText: {
color: colors.textSecondary,
fontSize: typography.metaSize
},
inlineErrorText: {
flex: 1,
minWidth: 0,
color: colors.statusRed,
fontSize: typography.metaSize
},
inlineRetryButton: {
minHeight: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
paddingHorizontal: spacing.md
},
inlineRetryText: {
color: colors.textPrimary,
fontSize: typography.metaSize,
fontWeight: '600'
},
state: {
flex: 1,
alignItems: 'center',
-3
View File
@@ -1,6 +1,3 @@
/* oxlint-disable max-lines -- Why: mobile dictation keeps permission, recording,
* chunk upload, completion, and cancellation in one hook so native audio state
* cannot drift from the runtime RPC lifecycle. */
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro
// can't resolve Node's builtin in a React Native bundle.
@@ -1,6 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { subscribeToDesktopNotifications } from './mobile-notifications'
import {
setScheduledNotificationsMaxForTests,
subscribeToDesktopNotifications
} from './mobile-notifications'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
@@ -268,4 +271,48 @@ describe('subscribeToDesktopNotifications', () => {
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()
}
})
})
@@ -31,10 +31,42 @@ type ScheduledNotificationState = {
const scheduledNotificationsByHostAndNotificationId = new Map<string, ScheduledNotificationState>()
// Why: notificationId embeds a per-completion timestamp (buildAgentNotificationId),
// so every agent-task-complete inserts a new, never-reused key. Entries are only
// removed when the desktop sends a matching dismiss — which a remote mobile user
// (not at the desktop) frequently never gets — so the map grew for the app's whole
// life. Bound it; a settled entry only retains a small identifier used for later
// programmatic dismissal, unnecessary for long-past completions.
const MAX_SCHEDULED_NOTIFICATIONS = 256
let maxScheduledNotifications = MAX_SCHEDULED_NOTIFICATIONS
function getStoredNotificationKey(hostId: string, notificationId: string): string {
return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}`
}
// Evict the oldest SETTLED entries (never one mid-schedule) until within the cap.
// Map iteration is insertion order, so the first match is the oldest.
function boundScheduledNotifications(): void {
while (scheduledNotificationsByHostAndNotificationId.size > maxScheduledNotifications) {
let evicted = false
for (const [key, state] of scheduledNotificationsByHostAndNotificationId) {
if (!state.pending) {
scheduledNotificationsByHostAndNotificationId.delete(key)
evicted = true
break
}
}
if (!evicted) {
break
}
}
}
/** Test-only: override the cap (pass no arg to restore the default). */
export function setScheduledNotificationsMaxForTests(max?: number): void {
maxScheduledNotifications = max ?? MAX_SCHEDULED_NOTIFICATIONS
}
export type NotificationPermissionState = {
granted: boolean
status: string
@@ -155,6 +187,7 @@ async function showLocalNotification(event: NotificationEvent, hostId: string):
return
}
notificationState.identifier = scheduledIdentifier
boundScheduledNotifications()
} finally {
if (notificationState.pending === pending) {
notificationState.pending = undefined
@@ -16,6 +16,7 @@ type Props = {
export function MobileSourceControlContent({ state }: Props) {
const {
insets,
connState,
busyAction,
commitMessage,
setCommitMessage,
@@ -56,6 +57,15 @@ export function MobileSourceControlContent({ state }: Props) {
return (
<>
{connState !== 'connected' ? (
// Why: once data has loaded the screen looks alive even when the
// desktop link is down, so taps appear to do nothing (STA-1511).
// Surface the reconnect state where the user is looking.
<View style={styles.reconnectBanner}>
<ActivityIndicator size="small" color={colors.statusAmber} />
<Text style={styles.reconnectBannerText}>Reconnecting to desktop...</Text>
</View>
) : null}
<View style={styles.summaryCard}>
<View style={styles.summaryHeader}>
<View style={styles.branchLine}>
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { resolveMobileHistoryScreenView } from './mobile-history-screen-state'
import type { MobileCommitRow } from './mobile-git-history'
const row: MobileCommitRow = {
id: 'abc123',
shortId: 'abc123',
subject: 'commit',
author: 'author',
parentId: null,
relativeTime: '1m'
}
describe('resolveMobileHistoryScreenView', () => {
it('shows waiting (not an endless spinner) when disconnected with nothing loaded', () => {
// Regression (STA-1511): the load effect no-ops while disconnected, so
// rows stay null forever — the screen must offer a retry, not a spinner.
expect(resolveMobileHistoryScreenView({ connected: false, rows: null, error: null })).toEqual({
kind: 'waiting'
})
})
it('spins only while connected and loading', () => {
expect(resolveMobileHistoryScreenView({ connected: true, rows: null, error: null })).toEqual({
kind: 'loading'
})
})
it('keeps already-loaded rows visible across a connection drop', () => {
expect(resolveMobileHistoryScreenView({ connected: false, rows: [row], error: null })).toEqual({
kind: 'rows',
rows: [row]
})
})
it('prefers the error state over waiting so failures stay actionable', () => {
expect(resolveMobileHistoryScreenView({ connected: false, rows: null, error: 'boom' })).toEqual(
{ kind: 'error', message: 'boom' }
)
})
it('distinguishes an empty history from a pending load', () => {
expect(resolveMobileHistoryScreenView({ connected: true, rows: [], error: null })).toEqual({
kind: 'empty'
})
})
})
@@ -0,0 +1,30 @@
import type { MobileCommitRow } from './mobile-git-history'
export type MobileHistoryScreenView =
| { kind: 'error'; message: string }
| { kind: 'waiting' }
| { kind: 'loading' }
| { kind: 'empty' }
| { kind: 'rows'; rows: MobileCommitRow[] }
// Why: the history load effect no-ops while the client isn't connected, so
// without an explicit 'waiting' view the screen showed an infinite spinner
// with no retry when opened during a reconnect window (STA-1511). Rows loaded
// before a drop stay visible — stale history beats a blank screen.
export function resolveMobileHistoryScreenView(input: {
connected: boolean
rows: MobileCommitRow[] | null
error: string | null
}): MobileHistoryScreenView {
const { connected, rows, error } = input
if (error) {
return { kind: 'error', message: error }
}
if (rows !== null) {
return rows.length === 0 ? { kind: 'empty' } : { kind: 'rows', rows }
}
if (!connected) {
return { kind: 'waiting' }
}
return { kind: 'loading' }
}
@@ -125,6 +125,24 @@ const baseStyles = StyleSheet.create({
fontWeight: '600',
textTransform: 'capitalize'
},
reconnectBanner: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginHorizontal: spacing.lg,
marginTop: spacing.lg,
marginBottom: -spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radii.button,
backgroundColor: colors.bgRaised,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.statusAmber
},
reconnectBannerText: {
color: colors.textPrimary,
fontSize: typography.metaSize
},
actionError: {
marginTop: spacing.sm,
paddingHorizontal: spacing.md,
@@ -0,0 +1,25 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const sessionRouteSource = readFileSync(
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
'utf8'
)
// Why: iOS terminates an active keyboard-dictation (and IME) session whenever
// JS writes a value into the focused field that differs from the native text
// (RN applies it via setTextAndSelection / _setAttributedString). Terminal
// inputs therefore must echo the raw field text in their controlled value and
// apply dash normalization only on the send/mirror path. See stablyai/orca#7925.
describe('terminal iOS dictation write-back', () => {
it('does not write normalized text back into the buffered command input value', () => {
expect(sessionRouteSource).toContain('onChangeText={setInput}')
expect(sessionRouteSource).not.toContain(
'setInput((previousText) => normalizeTerminalTextInput'
)
})
it('still normalizes the buffered command text at send time', () => {
expect(sessionRouteSource).toContain('normalizeTerminalTextInput(input)')
})
})
@@ -0,0 +1,82 @@
import { describe, expect, it, vi } from 'vitest'
import { dismissTerminalKeyboard } from './terminal-keyboard-dismiss'
describe('dismissTerminalKeyboard', () => {
it('clears pending live input focus before blur and dismiss', () => {
const calls: string[] = []
const clearPendingLiveInputFocus = vi.fn(() => calls.push('clear'))
const liveInput = { blur: vi.fn(() => calls.push('live-blur')) }
const commandInput = { blur: vi.fn(() => calls.push('command-blur')) }
const dismissKeyboard = vi.fn(() => calls.push('dismiss'))
dismissTerminalKeyboard({
clearPendingLiveInputFocus,
commandInput,
dismissKeyboard,
liveInput
})
expect(calls).toEqual(['clear', 'live-blur', 'command-blur', 'dismiss'])
})
it('blurs both live and buffered command inputs', () => {
const liveInput = { blur: vi.fn() }
const commandInput = { blur: vi.fn() }
dismissTerminalKeyboard({
clearPendingLiveInputFocus: vi.fn(),
commandInput,
dismissKeyboard: vi.fn(),
liveInput
})
expect(liveInput.blur).toHaveBeenCalledTimes(1)
expect(commandInput.blur).toHaveBeenCalledTimes(1)
})
it('dismisses the keyboard without a live input handle', () => {
const commandInput = { blur: vi.fn() }
const dismissKeyboard = vi.fn()
dismissTerminalKeyboard({
clearPendingLiveInputFocus: vi.fn(),
commandInput,
dismissKeyboard,
liveInput: null
})
expect(commandInput.blur).toHaveBeenCalledTimes(1)
expect(dismissKeyboard).toHaveBeenCalledTimes(1)
})
it('dismisses the keyboard without a buffered command input handle', () => {
const liveInput = { blur: vi.fn() }
const dismissKeyboard = vi.fn()
dismissTerminalKeyboard({
clearPendingLiveInputFocus: vi.fn(),
commandInput: undefined,
dismissKeyboard,
liveInput
})
expect(liveInput.blur).toHaveBeenCalledTimes(1)
expect(dismissKeyboard).toHaveBeenCalledTimes(1)
})
it('still clears focus and dismisses when both input handles are missing', () => {
const calls: string[] = []
const clearPendingLiveInputFocus = vi.fn(() => calls.push('clear'))
const dismissKeyboard = vi.fn(() => calls.push('dismiss'))
dismissTerminalKeyboard({
clearPendingLiveInputFocus,
commandInput: undefined,
dismissKeyboard,
liveInput: null
})
expect(calls).toEqual(['clear', 'dismiss'])
})
})
@@ -0,0 +1,17 @@
export type TerminalKeyboardDismissHandle = { blur: () => void } | null | undefined
export type DismissTerminalKeyboardOptions = {
clearPendingLiveInputFocus: () => void
commandInput: TerminalKeyboardDismissHandle
dismissKeyboard: () => void
liveInput: TerminalKeyboardDismissHandle
}
export function dismissTerminalKeyboard(options: DismissTerminalKeyboardOptions): void {
// Why: clear the queued live-input focus before blurring/dismissing so a
// pending deferred focus cannot re-open the iOS keyboard right after Hide.
options.clearPendingLiveInputFocus()
options.liveInput?.blur()
options.commandInput?.blur()
options.dismissKeyboard()
}
@@ -202,6 +202,32 @@ describe('terminal live input commit hook', () => {
await vi.waitFor(() => expect(sent).toEqual(['a', 'b']))
})
it('Given iOS smart-dash text When the change arrives Then the capture echoes the raw field text and the PTY gets normalized bytes', async () => {
// Given
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
// When: iOS smart punctuation rewrote "--" into an en dash inside the field
handlers.handleLiveInputChange('a–')
// Then: writing "a--" back into the controlled value would kill an active
// iOS dictation/IME session, so the capture must keep what iOS produced
expect(captures).toEqual(['a–'])
await vi.waitFor(() => expect(sent).toEqual(['a--']))
})
it('Given dictation-style hypothesis revisions When changes arrive Then the field is never rewritten and the PTY converges', async () => {
// Given
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
// When: iOS dictation replaces its hypothesis as recognition refines
handlers.handleLiveInputChange('high')
handlers.handleLiveInputChange('hi there')
// Then: captures only echo the field; the mirror repairs the PTY with DELs
expect(captures).toEqual(['high', 'hi there'])
await vi.waitFor(() => expect(sent).toEqual(['high', '\x7f\x7f there']))
})
it('Given a trailing space after Hangul When the change arrives Then the space commits the held syllable', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
@@ -109,9 +109,11 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
clearPendingLiveInputCommit()
return
}
const normalizedText = normalizeTerminalTextInput(text)
setLiveInputCapture(normalizedText)
applyLiveInputMirror(activeHandle, normalizedText)
// Why: iOS kills an active dictation/IME session when JS writes a value
// that differs from the native field text, so the controlled capture must
// echo the field verbatim; only the PTY mirror sees normalized text.
setLiveInputCapture(text)
applyLiveInputMirror(activeHandle, normalizeTerminalTextInput(text))
},
[
activeHandle,
+159
View File
@@ -0,0 +1,159 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ConnectionState } from './types'
import type { RpcClient } from './rpc-client'
const connectMock = vi.fn()
const loadHostsMock = vi.fn()
vi.mock('./rpc-client', () => ({
connect: (...args: unknown[]) => connectMock(...args)
}))
vi.mock('./host-store', () => ({
loadHosts: () => loadHostsMock()
}))
vi.mock('./connection-revival-triggers', () => ({
subscribeConnectionRevivalTriggers: () => () => {}
}))
import { RpcClientProvider, useCloseHost, useHostClient } from './client-context'
type FakeClient = RpcClient & {
emitState: (state: ConnectionState) => void
closeMock: ReturnType<typeof vi.fn>
}
function makeFakeClient(initialState: ConnectionState): FakeClient {
let state = initialState
const listeners = new Set<(state: ConnectionState) => void>()
const closeMock = vi.fn()
return {
sendRequest: vi.fn(),
subscribe: vi.fn(() => () => {}),
updateTerminalSubscriptionViewport: vi.fn(),
getState: () => state,
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
onStateChange: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
notifyForeground: vi.fn(),
close: closeMock,
closeMock,
emitState: (next) => {
state = next
for (const listener of listeners) {
listener(next)
}
}
} as FakeClient
}
const HOST = {
id: 'host-1',
name: 'Host 1',
endpoint: 'ws://127.0.0.1:1',
deviceToken: 'token',
publicKeyB64: 'key',
lastConnected: 0
}
type Harness = {
readonly hook: ReturnType<typeof useHostClient>
readonly closeHost: (hostId: string) => void
readonly unmount: () => void
}
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => spy.mockRestore()
}
async function renderHarness(hostId: string): Promise<Harness> {
let hook: ReturnType<typeof useHostClient> | null = null
let closeHost: ((hostId: string) => void) | null = null
let renderer: ReactTestRenderer | null = null
function Probe(): null {
hook = useHostClient(hostId)
closeHost = useCloseHost()
return null
}
const restore = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
})
} finally {
restore()
}
if (!hook || !closeHost || !renderer) {
throw new Error('harness did not render')
}
const mounted = renderer as ReactTestRenderer
return {
get hook() {
if (!hook) {
throw new Error('hook not rendered')
}
return hook
},
closeHost: (id) => {
if (!closeHost) {
throw new Error('closeHost not rendered')
}
closeHost(id)
},
unmount: () => mounted.unmount()
}
}
beforeEach(() => {
connectMock.mockReset()
loadHostsMock.mockReset()
})
describe('useHostClient', () => {
it('drops the closed client when the host entry is removed', async () => {
const fake = makeFakeClient('connected')
connectMock.mockReturnValue(fake)
loadHostsMock.mockResolvedValue([HOST])
const harness = await renderHarness(HOST.id)
expect(harness.hook.client).toBe(fake)
expect(harness.hook.state).toBe('connected')
// Regression (STA-1511): closeHost deletes the entry; before the fix the
// hook kept handing out the closed client, so mounted screens kept
// driving requests that could never resolve.
await act(async () => {
harness.closeHost(HOST.id)
})
expect(fake.closeMock).toHaveBeenCalled()
expect(harness.hook.client).toBeNull()
expect(harness.hook.state).toBe('disconnected')
harness.unmount()
})
it('reports disconnected instead of hanging when the host id is unknown', async () => {
loadHostsMock.mockResolvedValue([])
const harness = await renderHarness('missing-host')
expect(connectMock).not.toHaveBeenCalled()
expect(harness.hook.client).toBeNull()
expect(harness.hook.state).toBe('disconnected')
harness.unmount()
})
})
+11
View File
@@ -133,6 +133,11 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
return null
}
if (!host) {
// Why: returning silently leaves mounted screens on a permanent
// spinner (STA-1511) — surface 'disconnected' so they can render
// their waiting/retry affordance instead.
notifyHostState(hostId, 'disconnected')
notifyAllHosts()
return null
}
}
@@ -406,6 +411,12 @@ export function useHostClient(hostId: string | undefined): {
if (found && found.client !== clientRef.current) {
clientRef.current = found.client
force((n) => n + 1)
} else if (!found && clientRef.current) {
// Why: closeHost deletes the entry without a replacement; holding the
// closed client would let screens keep issuing requests that can never
// resolve (STA-1511). Null it so they render disconnected states.
clientRef.current = null
force((n) => n + 1)
}
})
const initial = ctx.acquire(hostId)
+4 -17
View File
@@ -1,25 +1,12 @@
import { defineConfig } from 'vitest/config'
const tsconfigRaw = JSON.stringify({
compilerOptions: {
jsx: 'react-jsx',
module: 'esnext',
moduleResolution: 'bundler',
strict: true,
target: 'es2022'
}
})
const vitestOxcConfig = { tsconfig: false } as never
export default defineConfig({
root: import.meta.dirname,
esbuild: {
tsconfigRaw
},
optimizeDeps: {
esbuildOptions: {
tsconfigRaw
}
},
// Why: the app tsconfig intentionally excludes tests; Vite 8's OXC transform
// otherwise fails before Vitest can run the test modules.
oxc: vitestOxcConfig,
test: {
environment: 'node',
include: ['src/**/*.test.ts']
@@ -0,0 +1,35 @@
// Prints the app's macOS notification settings as JSON and exits.
//
// Why this exists: Electron exposes no API for UNUserNotificationCenter
// authorization, and scheduling silently succeeds even while macOS suppresses
// display, so the renderer cannot know whether the user actually receives
// notifications. This binary must run from inside the app bundle (NSBundle
// resolves the bundle by walking up from the executable path) and must be
// code-signed with the app's identifier — macOS keys notification records to
// the signing identifier, which is why the build embeds an Info.plist section
// with the target CFBundleIdentifier.
import Foundation
import UserNotifications
let semaphore = DispatchSemaphore(value: 0)
var authorization = "unknown"
var alert = "unknown"
UNUserNotificationCenter.current().getNotificationSettings { settings in
switch settings.authorizationStatus {
case .authorized: authorization = "authorized"
case .provisional: authorization = "provisional"
case .ephemeral: authorization = "ephemeral"
case .denied: authorization = "denied"
case .notDetermined: authorization = "not-determined"
@unknown default: authorization = "unknown"
}
switch settings.alertSetting {
case .enabled: alert = "enabled"
case .disabled: alert = "disabled"
case .notSupported: alert = "not-supported"
@unknown default: alert = "unknown"
}
semaphore.signal()
}
_ = semaphore.wait(timeout: .now() + 3)
print("{\"authorization\":\"\(authorization)\",\"alert\":\"\(alert)\"}")
+6 -2
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.124-rc.9",
"version": "1.4.131-rc.1",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -11,7 +11,7 @@
"main": "./out/main/index.js",
"scripts": {
"format": "oxfmt --write .",
"lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage",
"lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage",
"lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json",
"lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs",
"lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet",
@@ -19,6 +19,7 @@
"test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts",
"check:styled-scrollbars": "node config/scripts/check-styled-scrollbars.mjs",
"check:reliability-gates": "node config/scripts/check-reliability-gates.mjs",
"check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
"check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs",
"verify:macos-entitlements": "node config/scripts/verify-macos-entitlements.mjs",
"vendor:feature-wall-assets": "node config/scripts/vendor-feature-wall-assets.mjs",
@@ -41,6 +42,7 @@
"dev:web": "vite --config vite.web.config.ts --host 127.0.0.1",
"build:relay": "node config/scripts/build-relay.mjs",
"build:computer-macos": "node config/scripts/build-computer-macos.mjs",
"build:notification-status-macos": "node config/scripts/build-notification-status-macos.mjs",
"build:native": "node config/scripts/build-native-for-platform.mjs",
"smoke:computer": "node config/scripts/computer-use-smoke.mjs",
"verify:computer-native": "node config/scripts/verify-computer-native.mjs",
@@ -80,12 +82,14 @@
"test:e2e:terminal-perf:check-report": "node config/scripts/check-terminal-perf-report-budgets.mjs",
"test:e2e:terminal-perf:summarize": "node config/scripts/summarize-terminal-perf-report.mjs",
"test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs",
"win-update-e2e": "node tools/win-update-e2e/run.mjs",
"test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs",
"test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful",
"test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts",
"bench:idle-cpu": "pnpm run ensure:electron-runtime && node config/scripts/run-idle-cpu-benchmark.mjs",
"bench:startup": "pnpm run ensure:electron-runtime && node tools/benchmarks/startup-time-bench.mjs",
"bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tools/benchmarks/daemon-coldstart-bench.mjs",
"bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tools/benchmarks/main-thread-jank-bench.mjs",
"bench:compare": "node config/scripts/compare-benchmark-artifacts.mjs"
},
"dependencies": {
-523
View File
@@ -1,523 +0,0 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mobile Driver Overlay — Orca Preview</title>
<style>
/* ── Orca dark tokens (from main.css .dark) ── */
:root {
--background: #0a0a0a;
--foreground: #fafafa;
--card: #171717;
--card-foreground: #fafafa;
--primary: #e5e5e5;
--primary-foreground: #171717;
--muted: #262626;
--muted-foreground: #a1a1a1;
--accent: #404040;
--accent-foreground: #fafafa;
--border: rgb(255 255 255 / 0.07);
--input: rgb(255 255 255 / 0.15);
--ring: #737373;
--radius: 0.625rem;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
--orca-pane-title-height: 24px;
--orca-pane-title-fg: rgb(255 255 255 / 0.52);
--orca-pane-title-separator: rgb(255 255 255 / 0.06);
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--xterm-bg: #000000;
--xterm-fg: #cccccc;
--xterm-green: #23d18b;
--xterm-blue: #3b8eea;
--xterm-dim: #6a6a6a;
}
@font-face {
font-family: 'Geist';
src: url('../src/renderer/src/assets/fonts/Geist-Variable.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
font-family: 'Geist', ui-sans-serif, system-ui, sans-serif;
background: var(--background);
color: var(--foreground);
letter-spacing: 0.01em;
}
/* ── Preview chrome (switcher only — not part of Orca UI) ── */
.preview-chrome {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.625rem 1rem;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--card) 92%, transparent);
backdrop-filter: blur(8px);
}
.preview-chrome h1 {
margin: 0;
font-size: 0.8125rem;
font-weight: 600;
color: var(--muted-foreground);
}
.state-tabs {
display: flex;
gap: 0.375rem;
}
.state-tab {
height: 1.75rem;
padding: 0 0.75rem;
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 2px);
background: transparent;
color: var(--muted-foreground);
font: inherit;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
}
.state-tab[aria-selected='true'] {
border-color: var(--input);
background: color-mix(in srgb, var(--input) 30%, transparent);
color: var(--foreground);
}
/* ── Orca workspace frame ── */
.workspace {
height: 100%;
padding-top: 3rem;
display: flex;
flex-direction: column;
}
.workspace-main {
flex: 1;
min-height: 0;
padding: 0.5rem;
}
/* Terminal pane — matches .pane + .terminal-container */
.pane {
position: relative;
height: 100%;
min-height: 28rem;
overflow: hidden;
background: var(--xterm-bg);
}
.pane[data-has-title]::after {
content: '';
position: absolute;
top: var(--orca-pane-title-height);
left: -5px;
right: -5px;
height: 1px;
background: var(--orca-pane-title-separator);
z-index: 6;
pointer-events: none;
}
.pane-title-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: var(--orca-pane-title-height);
z-index: 5;
display: flex;
align-items: center;
padding: 0 8px;
font-size: 13px;
font-family: var(--font-mono);
color: var(--orca-pane-title-fg);
background: transparent;
user-select: none;
}
.xterm-container {
position: relative;
width: calc(100% - 4px);
height: calc(100% - var(--orca-pane-title-height));
margin-top: var(--orca-pane-title-height);
margin-left: 4px;
}
.xterm-screen {
position: absolute;
inset: 0;
padding: 8px 10px;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.35;
color: var(--xterm-fg);
white-space: pre;
overflow: hidden;
}
.xterm-screen .green {
color: var(--xterm-green);
}
.xterm-screen .blue {
color: var(--xterm-blue);
}
.xterm-screen .dim {
color: var(--xterm-dim);
}
/* ── MobileDriverOverlay (from MobileDriverOverlay.tsx) ── */
.mobile-driver-banner {
pointer-events: none;
position: absolute;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.mobile-driver-banner.is-chip {
align-items: flex-start;
justify-content: flex-end;
padding: 0;
}
.overlay-card {
pointer-events: auto;
display: flex;
width: 100%;
max-width: 30rem;
flex-direction: column;
gap: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card);
color: var(--card-foreground);
padding: 1.5rem 1.5rem 1.25rem;
box-shadow: var(--shadow-xs);
}
.overlay-header {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.phone-icon-badge {
display: flex;
width: 2.5rem;
height: 2.5rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: 9999px;
}
.phone-icon-badge.tone-driving {
background: var(--muted);
}
.phone-icon-badge.tone-held {
background: color-mix(in srgb, var(--muted) 60%, transparent);
}
.phone-icon-badge svg {
width: 1.25rem;
height: 1.25rem;
color: var(--foreground);
}
.overlay-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 0.25rem;
}
.overlay-eyebrow {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.025em;
text-transform: uppercase;
}
.overlay-eyebrow.tone-driving {
color: var(--foreground);
}
.overlay-eyebrow.tone-held {
color: var(--muted-foreground);
}
.live-dot {
width: 0.375rem;
height: 0.375rem;
border-radius: 9999px;
background: var(--foreground);
}
.overlay-title {
font-size: 1rem;
font-weight: 600;
line-height: 1.25;
}
.overlay-body {
font-size: 0.875rem;
line-height: 1.625;
color: var(--muted-foreground);
}
.overlay-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.25rem;
}
/* Button primitive (button.tsx, dark theme) */
[data-slot='button'] {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
gap: 0.375rem;
border-radius: calc(var(--radius) - 2px);
border: 1px solid transparent;
font-size: 0.875rem;
font-weight: 500;
white-space: nowrap;
cursor: default;
outline: none;
transition: all 150ms ease;
}
[data-slot='button'][data-size='sm'] {
height: 2rem;
padding: 0 0.75rem;
}
[data-slot='button'][data-size='xs'] {
height: 1.5rem;
gap: 0.25rem;
padding: 0 0.5rem;
font-size: 0.75rem;
}
[data-slot='button'][data-variant='default'] {
background: var(--primary);
color: var(--primary-foreground);
}
[data-slot='button'][data-variant='default'].is-focused {
border-color: var(--ring);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 50%, transparent);
}
[data-slot='button'][data-variant='outline'] {
border-color: var(--input);
background: color-mix(in srgb, var(--input) 30%, transparent);
color: var(--foreground);
box-shadow: var(--shadow-xs);
}
[data-slot='button'][data-variant='ghost'] {
background: transparent;
color: var(--foreground);
padding-left: 0.25rem;
padding-right: 0.25rem;
}
/* LockChip */
.mobile-driver-banner .lock-chip {
pointer-events: auto;
position: absolute;
right: 0.5rem;
top: 0.5rem;
z-index: 50;
display: flex;
align-items: center;
gap: 0.375rem;
border: 1px solid var(--border);
border-radius: 9999px;
background: var(--card);
color: var(--card-foreground);
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
box-shadow: var(--shadow-xs);
}
.lock-chip svg {
width: 0.75rem;
height: 0.75rem;
color: var(--foreground);
}
.state-panel[hidden] {
display: none !important;
}
</style>
</head>
<body>
<div class="preview-chrome">
<h1>Mobile Driver Overlay — production layout preview</h1>
<div class="state-tabs" role="tablist" aria-label="Overlay state">
<button class="state-tab" type="button" role="tab" data-state="driving" aria-selected="false">
Phone in control
</button>
<button class="state-tab" type="button" role="tab" data-state="held" aria-selected="true">
Phone session ended
</button>
<button class="state-tab" type="button" role="tab" data-state="chip" aria-selected="false">
Collapsed chip
</button>
</div>
</div>
<div class="workspace">
<div class="workspace-main">
<div class="pane" data-has-title>
<div class="pane-title-bar">
<span class="pane-title-text">bash — fix-phone-resizing</span>
</div>
<div class="xterm-container">
<div class="xterm-screen" aria-hidden="true">
<span class="green">➜</span> fix-phone-resizing <span class="dim">git:(</span><span class="blue">fix-phone-resizing</span><span class="dim">) ✗</span>
<span class="green">➜</span> fix-phone-resizing <span class="dim">git status</span>
On branch fix-phone-resizing
Changes not staged for commit:
<span class="dim">modified:</span> src/renderer/src/components/terminal-pane/MobileDriverOverlay.tsx
<span class="dim">modified:</span> src/renderer/src/i18n/locales/en.json
<span class="green">➜</span> fix-phone-resizing <span class="dim">_</span></div>
<!-- Driving -->
<div class="state-panel mobile-driver-banner" id="panel-driving" role="dialog" hidden>
<div class="overlay-card">
<div class="overlay-header">
<div class="phone-icon-badge tone-driving">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
<path d="M12 18h.01" />
</svg>
</div>
<div class="overlay-copy">
<div class="overlay-eyebrow tone-driving">
<span class="live-dot" aria-hidden="true"></span>
<span>From your phone</span>
</div>
<div class="overlay-title">Your phone is in control</div>
</div>
</div>
<div class="overlay-body">
Desktop keyboard is paused. Take back this terminal to type here, take back all terminals your phone controls, or collapse to keep watching.
</div>
<div class="overlay-actions">
<button data-slot="button" data-variant="outline" data-size="sm" type="button">Collapse</button>
<button data-slot="button" data-variant="outline" data-size="sm" type="button">Take back all terminals</button>
<button data-slot="button" data-variant="default" data-size="sm" type="button" class="is-focused">Take back this terminal</button>
</div>
</div>
</div>
<!-- Held -->
<div class="state-panel mobile-driver-banner" id="panel-held" role="dialog">
<div class="overlay-card">
<div class="overlay-header">
<div class="phone-icon-badge tone-held">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
<path d="M12 18h.01" />
</svg>
</div>
<div class="overlay-copy">
<div class="overlay-eyebrow tone-held">
<span>From your phone</span>
</div>
<div class="overlay-title">Your phone left this at phone size</div>
</div>
</div>
<div class="overlay-body">
Your phone session ended. Restore to desktop size for this terminal, or for all terminals your phone left at phone size.
</div>
<div class="overlay-actions">
<button data-slot="button" data-variant="outline" data-size="sm" type="button">Restore all terminals</button>
<button data-slot="button" data-variant="default" data-size="sm" type="button" class="is-focused">Restore this terminal</button>
</div>
</div>
</div>
<!-- Chip -->
<div class="state-panel mobile-driver-banner is-chip" id="panel-chip" hidden>
<div class="lock-chip mobile-driver-banner">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
<path d="M12 18h.01" />
</svg>
<button data-slot="button" data-variant="ghost" data-size="xs" type="button">Phone driving</button>
<button data-slot="button" data-variant="default" data-size="xs" type="button">Take back</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const tabs = document.querySelectorAll('.state-tab')
const panels = {
driving: document.getElementById('panel-driving'),
held: document.getElementById('panel-held'),
chip: document.getElementById('panel-chip')
}
function showState(state) {
for (const [key, panel] of Object.entries(panels)) {
panel.hidden = key !== state
}
tabs.forEach((tab) => {
tab.setAttribute('aria-selected', tab.dataset.state === state ? 'true' : 'false')
})
}
tabs.forEach((tab) => {
tab.addEventListener('click', () => showState(tab.dataset.state))
})
</script>
</body>
</html>
-1
View File
@@ -1,4 +1,3 @@
/* eslint-disable max-lines -- Why: this file groups every CLI browser-command test (page targeting, profiles, waits, viewport) so test-fixture imports and the runtime-client mock stay shared in one place. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const callMock = vi.fn()
-1
View File
@@ -1,4 +1,3 @@
/* eslint-disable max-lines -- Why: file CLI coverage shares one mocked runtime setup across command contracts. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
const callMock = vi.fn()
+32
View File
@@ -434,6 +434,38 @@ describe('orca cli worktree awareness', () => {
expect(logSpy).toHaveBeenCalledTimes(1)
})
it('resolves the invocation cwd from ORCA_CLI_CWD when no cwd is passed', async () => {
// Why: the SSH relay bridge runs the CLI on the Orca host with the remote
// shell's cwd carried in ORCA_CLI_CWD (#7716); cwd-based selectors must
// resolve against it, not the host process cwd.
process.env.ORCA_CLI_CWD = '/tmp/repo/feature/src'
try {
queueFixtures(
callMock,
worktreeListFixture([
buildWorktree('/tmp/repo', 'main'),
buildWorktree('/tmp/repo/feature', 'feature/foo')
]),
okFixture('req_1', {
worktree: {
id: 'repo::/tmp/repo/feature',
branch: 'feature/foo',
path: '/tmp/repo/feature'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['worktree', 'current', '--json'])
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', {
worktree: 'id:repo::/tmp/repo/feature'
})
} finally {
delete process.env.ORCA_CLI_CWD
}
})
it.skipIf(process.platform === 'win32')(
'prepares and starts Claude Agent Teams in the current Orca terminal',
async () => {
+13 -1
View File
@@ -25,7 +25,19 @@ function shouldIgnoreRemoteSelection(commandPath: string[]): boolean {
)
}
export async function main(argv = process.argv.slice(2), cwd = process.cwd()): Promise<void> {
// Why: the SSH relay bridge executes this CLI on the Orca host while the
// caller's shell cwd lives on the remote machine (which cannot be chdir'd
// into). ORCA_CLI_CWD carries that remote cwd so cwd-based selectors like
// `--worktree active` resolve against the caller's directory.
function resolveInvocationCwd(): string {
const override = process.env.ORCA_CLI_CWD
return typeof override === 'string' && override.length > 0 ? override : process.cwd()
}
export async function main(
argv = process.argv.slice(2),
cwd = resolveInvocationCwd()
): Promise<void> {
if (argv[0] === 'agent-teams-tmux') {
await runAgentTeamsTmuxShim(argv.slice(1))
return
@@ -1,4 +1,3 @@
/* eslint-disable max-lines -- Why: this fixture verifies the shared remote hook installer fake across every managed agent so SSH regressions are caught together. */
import { describe, expect, it, vi } from 'vitest'
import type { SFTPWrapper } from 'ssh2'
@@ -9,6 +8,7 @@ vi.mock('electron', () => ({
}))
import { CodexHookService } from '../codex/hook-service'
import { DroidHookService } from '../droid/hook-service'
import { CursorHookService } from '../cursor/hook-service'
import { CommandCodeHookService } from '../command-code/hook-service'
import { GeminiHookService } from '../gemini/hook-service'
@@ -21,6 +21,24 @@ import { HermesHookService } from '../hermes/hook-service'
import { DevinHookService } from '../devin/hook-service'
import { KimiHookService } from '../kimi/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
import { ampHookService } from '../amp/hook-service'
import { antigravityHookService } from '../antigravity/hook-service'
import { claudeHookService } from '../claude/hook-service'
import { codexHookService } from '../codex/hook-service'
import { copilotHookService } from '../copilot/hook-service'
import { cursorHookService } from '../cursor/hook-service'
import { droidHookService } from '../droid/hook-service'
import { commandCodeHookService } from '../command-code/hook-service'
import { geminiHookService } from '../gemini/hook-service'
import { devinHookService } from '../devin/hook-service'
import { grokHookService } from '../grok/hook-service'
import { hermesHookService } from '../hermes/hook-service'
import { kimiHookService } from '../kimi/hook-service'
import { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-controls'
import {
installRemoteManagedAgentHooks,
REMOTE_MANAGED_HOOK_INSTALLER_AGENTS
} from './remote-managed-hook-installers'
type FakeFs = {
files: Map<string, string>
@@ -167,6 +185,10 @@ describe('remote hook service installers', () => {
{
path: '/home/dev/.orca/agent-hooks/devin-hook.sh',
install: (sftp: SFTPWrapper) => new DevinHookService().installRemote(sftp, '/home/dev')
},
{
path: '/home/dev/.orca/agent-hooks/droid-hook.sh',
install: (sftp: SFTPWrapper) => new DroidHookService().installRemote(sftp, '/home/dev')
}
]
@@ -583,6 +605,107 @@ describe('remote hook service installers', () => {
expect(fs.modes.get('/home/dev/.orca/agent-hooks/copilot-hook.sh')).toBe(0o755)
})
// Why: Droid (and Copilot) each shipped a working installRemote but were never
// registered in REMOTE_MANAGED_HOOK_INSTALLERS, so their status silently never
// appeared over SSH (issue #7253). Guard the whole bug class, not one agent:
// every locally-managed hook service that implements installRemote MUST be
// wired into the remote installer.
it('registers every managed agent that implements installRemote in the remote installer (issue #7253)', () => {
const servicesByAgent = new Map<string, { installRemote?: unknown }>([
['claude', claudeHookService],
['openclaude', openClaudeHookService],
['codex', codexHookService],
['gemini', geminiHookService],
['antigravity', antigravityHookService],
['amp', ampHookService],
['cursor', cursorHookService],
['droid', droidHookService],
['command-code', commandCodeHookService],
['grok', grokHookService],
['copilot', copilotHookService],
['hermes', hermesHookService],
['devin', devinHookService],
['kimi', kimiHookService]
])
// Guard against a service silently missing from the map above as new agents land.
for (const [agent] of MANAGED_AGENT_HOOK_INSTALLERS) {
expect(servicesByAgent.has(agent)).toBe(true)
}
const registered = new Set<string>(REMOTE_MANAGED_HOOK_INSTALLER_AGENTS)
const missing: string[] = []
for (const [agent, service] of servicesByAgent) {
if (typeof service.installRemote === 'function' && !registered.has(agent)) {
missing.push(agent)
}
}
expect(missing).toEqual([])
})
it('installs Droid and Copilot when running the aggregate remote installer (issue #7253)', async () => {
const { sftp } = createFakeSftp()
const results = await installRemoteManagedAgentHooks(sftp, '/home/dev')
const byAgent = new Map(results.map((r) => [r.agent, r.state]))
expect(byAgent.get('droid')).toBe('installed')
expect(byAgent.get('copilot')).toBe('installed')
})
it('installs remote Droid hooks into Factory settings.json (issue #7253)', async () => {
const { sftp, fs } = createFakeSftp()
const status = await new DroidHookService().installRemote(sftp, '/home/dev')
expect(status.state).toBe('installed')
expect(status.configPath).toBe('/home/dev/.factory/settings.json')
const config = JSON.parse(fs.files.get('/home/dev/.factory/settings.json')!) as {
hooks: Record<string, { matcher?: string; hooks?: { command: string }[] }[]>
}
for (const eventName of [
'SessionStart',
'UserPromptSubmit',
'Stop',
'SubagentStop',
'PreToolUse',
'PostToolUse',
'PermissionRequest',
'Notification'
]) {
const definition = config.hooks[eventName]?.[0]
const command = definition?.hooks?.[0]?.command
expect(command).toContain('/home/dev/.orca/agent-hooks/droid-hook.sh')
expect(command).toMatch(/^if \[ -x /)
}
// Tool/permission events carry a `*` matcher; lifecycle events do not.
expect(config.hooks.PreToolUse?.[0]?.matcher).toBe('*')
expect(config.hooks.PostToolUse?.[0]?.matcher).toBe('*')
expect(config.hooks.PermissionRequest?.[0]?.matcher).toBe('*')
expect(config.hooks.Stop?.[0]?.matcher).toBeUndefined()
const script = fs.files.get('/home/dev/.orca/agent-hooks/droid-hook.sh')
expect(script).toContain('#!/bin/sh')
expect(script).toContain('/hook/droid')
expect(fs.modes.get('/home/dev/.orca/agent-hooks/droid-hook.sh')).toBe(0o755)
})
it('does not overwrite a malformed remote Factory settings.json', async () => {
const original = '{"hooks": }'
const { sftp, fs } = createFakeSftp({
'/home/dev/.factory/settings.json': original
})
const status = await new DroidHookService().installRemote(sftp, '/home/dev')
expect(status).toMatchObject({
agent: 'droid',
state: 'error',
configPath: '/home/dev/.factory/settings.json',
managedHooksPresent: false,
detail: 'Could not parse remote Factory settings.json'
})
expect(fs.files.get('/home/dev/.factory/settings.json')).toBe(original)
expect(fs.files.get('/home/dev/.orca/agent-hooks/droid-hook.sh')).toBeUndefined()
})
it('installs remote Hermes plugin files and enables the plugin', async () => {
const { sftp, fs } = createFakeSftp()
@@ -7,7 +7,9 @@ import { geminiHookService } from '../gemini/hook-service'
import { antigravityHookService } from '../antigravity/hook-service'
import { cursorHookService } from '../cursor/hook-service'
import { commandCodeHookService } from '../command-code/hook-service'
import { copilotHookService } from '../copilot/hook-service'
import { devinHookService } from '../devin/hook-service'
import { droidHookService } from '../droid/hook-service'
import { grokHookService } from '../grok/hook-service'
import { hermesHookService } from '../hermes/hook-service'
import { kimiHookService } from '../kimi/hook-service'
@@ -27,12 +29,20 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [
['amp', (sftp, remoteHome) => ampHookService.installRemote(sftp, remoteHome)],
['cursor', (sftp, remoteHome) => cursorHookService.installRemote(sftp, remoteHome)],
['command-code', (sftp, remoteHome) => commandCodeHookService.installRemote(sftp, remoteHome)],
['copilot', (sftp, remoteHome) => copilotHookService.installRemote(sftp, remoteHome)],
['grok', (sftp, remoteHome) => grokHookService.installRemote(sftp, remoteHome)],
['droid', (sftp, remoteHome) => droidHookService.installRemote(sftp, remoteHome)],
['hermes', (sftp, remoteHome) => hermesHookService.installRemote(sftp, remoteHome)],
['devin', (sftp, remoteHome) => devinHookService.installRemote(sftp, remoteHome)],
['kimi', (sftp, remoteHome) => kimiHookService.installRemote(sftp, remoteHome)]
]
/** Agents wired into the remote (SSH) hook installer. Exported so an invariant
* test can assert every locally-managed agent that implements `installRemote`
* is registered here — the omission that hid Droid/Copilot status over SSH. */
export const REMOTE_MANAGED_HOOK_INSTALLER_AGENTS: readonly AgentHookInstallStatus['agent'][] =
REMOTE_MANAGED_HOOK_INSTALLERS.map(([agent]) => agent)
export async function installRemoteManagedAgentHooks(
sftp: SFTPWrapper,
remoteHome: string
+26 -1
View File
@@ -13,7 +13,12 @@ import {
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { AgentHookServer, agentHookServer, _internals } from './server'
import {
AgentHookServer,
agentHookServer,
CLOSED_AGENT_STATUS_TAB_IDS_MAX,
_internals
} from './server'
import {
AGENT_STATUS_MAX_FIELD_LENGTH,
AGENT_STATUS_STALE_AFTER_MS,
@@ -6848,3 +6853,23 @@ describe('AgentHookServer ingestTerminalStatus', () => {
expect(server.getStatusSnapshot()).toEqual([])
})
})
describe('AgentHookServer closed-tab suppression bound', () => {
it('bounds closedAgentStatusTabIds with LRU eviction as tabs close', () => {
const server = new AgentHookServer()
const internals = server as unknown as {
markTabClosedForAgentStatus: (tabId: string) => void
closedAgentStatusTabIds: Set<string>
}
const total = CLOSED_AGENT_STATUS_TAB_IDS_MAX + 200
for (let i = 0; i < total; i += 1) {
internals.markTabClosedForAgentStatus(`closed-tab-${i}`)
}
// Set stays bounded; oldest ids are evicted, most-recent are retained.
expect(internals.closedAgentStatusTabIds.size).toBe(CLOSED_AGENT_STATUS_TAB_IDS_MAX)
expect(internals.closedAgentStatusTabIds.has('closed-tab-0')).toBe(false)
expect(internals.closedAgentStatusTabIds.has(`closed-tab-${total - 1}`)).toBe(true)
})
})
+16
View File
@@ -119,6 +119,12 @@ const AGENT_PROMPT_SENT_AGENT_KINDS = new Set<AgentKind>(AGENT_KIND_VALUES)
// not resurrect on hydrate.
const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
// Why: closed-tab suppression only needs to cover recently closed tabs — a
// status event for a long-closed tab cannot arrive once its process/hooks are
// gone. Bound the set so it can't grow one entry per tab close for the whole
// session (it is otherwise only cleared at app quit).
export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024
type LastStatusFile = {
version: number
entries: Record<string, EnrichedAgentHookEventPayload>
@@ -596,7 +602,17 @@ export class AgentHookServer {
}
private markTabClosedForAgentStatus(tabId: string): void {
// Delete-then-add keeps recently closed tabs most-recent so eviction only
// sheds the oldest ids, which can no longer receive status events.
this.closedAgentStatusTabIds.delete(tabId)
this.closedAgentStatusTabIds.add(tabId)
while (this.closedAgentStatusTabIds.size > CLOSED_AGENT_STATUS_TAB_IDS_MAX) {
const oldest = this.closedAgentStatusTabIds.keys().next().value
if (oldest === undefined) {
break
}
this.closedAgentStatusTabIds.delete(oldest)
}
}
private shouldSuppressClosedTabStatus(paneKey: string): boolean {
@@ -5,17 +5,15 @@ import { parseCodexSessionContent } from './session-scanner-codex-parser'
import { parseDevinSessionContent } from './session-scanner-devin-parser'
import { parseDroidSessionContent } from './session-scanner-droid-parser'
import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers'
import {
parseClaudeSessionContent,
parseGeminiSessionContent
} from './session-scanner-primary-parsers'
import { parseClaudeSessionContent } from './session-scanner-primary-parsers'
import { parseGeminiSessionContent } from './session-scanner-gemini-parsers'
import {
parseCopilotSessionContent,
parseCursorSessionContent,
parseHermesSessionContent
} from './session-scanner-secondary-parsers'
import type { FileWithMtime } from './session-scanner-types'
import { normalizePiSessionsDir } from './session-scanner-values'
import { normalizeAgentSessionsDir } from './session-scanner-values'
import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index'
import type {
RemoteParserOptions,
@@ -36,13 +34,19 @@ export function remoteSessionSources(
): RemoteSessionSource[] {
return [
...remoteCodexSources(remoteHome, hostPlatform),
jsonlSource(
'claude',
remoteHome,
hostPlatform,
['.claude', 'projects'],
parseClaudeSessionContent
),
{
...jsonlSource(
'claude',
remoteHome,
hostPlatform,
['.claude', 'projects'],
parseClaudeSessionContent
),
// The remote host owns the transcript disk, so the local readdir in the
// Claude parser is skipped; the walked listing supplies the sibling
// subagent counts instead.
collectSubagentSiblingCounts: true
},
source(
'gemini',
remoteHome,
@@ -83,6 +87,7 @@ export function remoteSessionSources(
parseDevinSessionContent
),
jsonlSource('pi', remoteHome, hostPlatform, remotePiSessionsSegments(), piParser),
jsonlSource('omp', remoteHome, hostPlatform, remoteOmpSessionsSegments(), ompParser),
jsonlSource(
'droid',
remoteHome,
@@ -203,6 +208,15 @@ function piParser(
return parseMessageGraphSessionContent('pi', file, content, platform, options)
}
function ompParser(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform,
options: RemoteParserOptions
): Promise<AiVaultSession | null> {
return parseMessageGraphSessionContent('omp', file, content, platform, options)
}
function openClawParser(
file: FileWithMtime,
content: string,
@@ -217,5 +231,9 @@ function remotePathSegments(path: string): string[] {
}
function remotePiSessionsSegments(): string[] {
return normalizePiSessionsDir('/.pi/agent/sessions').split('/').filter(Boolean)
return normalizeAgentSessionsDir('/.pi/agent/sessions', '.pi').split('/').filter(Boolean)
}
function remoteOmpSessionsSegments(): string[] {
return normalizeAgentSessionsDir('/.omp/agent/sessions', '.omp').split('/').filter(Boolean)
}
@@ -21,6 +21,9 @@ export type RemoteSessionSource = {
rootDir: string
extensions: readonly string[]
filePredicate?: (path: string) => boolean
// Claude layout: count `<session>/subagents/*.jsonl` siblings from the walked
// listing and drop them from candidates instead of indexing them as sessions.
collectSubagentSiblingCounts?: boolean
parse: (
file: FileWithMtime,
content: string,
@@ -31,4 +34,5 @@ export type RemoteSessionSource = {
export type RemoteSessionCandidate = {
source: RemoteSessionSource
file: FileWithMtime
subagentTranscriptCount?: number
}
@@ -201,6 +201,79 @@ describe('scanRemoteAiVaultSessions', () => {
})
})
it('counts remote sibling subagent transcripts for zero-turn Claude sessions', async () => {
const provider = new MemoryRemoteProvider()
provider.addFile(
'/home/ada/.claude/projects/repo/lost-session.jsonl',
jsonLines([{ type: 'mode', mode: 'default', sessionId: 'lost-session' }]),
50
)
provider.addFile(
'/home/ada/.claude/projects/repo/lost-session/subagents/agent-a.jsonl',
jsonLines([{ type: 'user', message: { role: 'user', content: 'Subtask A' } }]),
51
)
provider.addFile(
'/home/ada/.claude/projects/repo/lost-session/subagents/agent-b.jsonl',
jsonLines([{ type: 'user', message: { role: 'user', content: 'Subtask B' } }]),
52
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(result.issues).toEqual([])
// Subagent transcripts must not surface as standalone sessions; they only
// contribute recoverable signal to their zero-turn parent.
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({
agent: 'claude',
sessionId: 'lost-session',
messageCount: 0,
subagentTranscriptCount: 2,
filePath: '/home/ada/.claude/projects/repo/lost-session.jsonl'
})
})
it('ignores remote subagent siblings for Claude sessions with real turns', async () => {
const provider = new MemoryRemoteProvider()
provider.addFile(
'/home/ada/.claude/projects/repo/live-session.jsonl',
jsonLines([
{
sessionId: 'live-session',
type: 'user',
message: { content: [{ type: 'text', text: 'Do the thing' }] }
}
]),
60
)
provider.addFile(
'/home/ada/.claude/projects/repo/live-session/subagents/agent-a.jsonl',
jsonLines([{ type: 'user', message: { role: 'user', content: 'Subtask' } }]),
61
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({
sessionId: 'live-session',
messageCount: 1,
subagentTranscriptCount: 0
})
})
it('builds resume commands with the remote host platform', async () => {
const provider = new MemoryRemoteProvider()
provider.addFile(
+19 -3
View File
@@ -11,6 +11,7 @@ import type { FileStat, IFilesystemProvider } from '../providers/types'
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
import { sessionSortTime } from './session-scanner-accumulator'
import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts'
import type { FileWithMtime } from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
import { remoteSessionSources } from './remote-session-scanner-sources'
@@ -77,11 +78,15 @@ async function discoverRemoteSourceCandidates(args: {
context: RemoteScannerContext
issues: AiVaultScanIssue[]
}): Promise<RemoteSessionCandidate[]> {
const paths = await walkRemoteSessionFiles(
const walked = await walkRemoteSessionFiles(
args.source,
args.context.provider,
args.context.hostPlatform
)
const partition = args.source.collectSubagentSiblingCounts
? partitionSubagentTranscriptPaths(walked)
: null
const paths = partition ? partition.sessionFilePaths : walked
const files = await mapRemoteScanConcurrently(paths, (path) =>
statRemoteFile(
args.context.provider,
@@ -93,7 +98,11 @@ async function discoverRemoteSourceCandidates(args: {
)
return files
.filter((file): file is FileWithMtime => Boolean(file))
.map((file) => ({ source: args.source, file }))
.map((file) => ({
source: args.source,
file,
subagentTranscriptCount: partition?.subagentTranscriptCounts.get(file.path) ?? 0
}))
}
async function walkRemoteSessionFiles(
@@ -199,7 +208,14 @@ async function parseRemoteSessionCandidate(
if (read.isBinary) {
return null
}
return candidate.source.parse(candidate.file, read.content, context)
const session = await candidate.source.parse(candidate.file, read.content, context)
// Mirror the local zero-turn-only rule: sibling subagent transcripts are
// recoverable signal only when the parent conversation persisted no turns.
const subagentTranscriptCount = candidate.subagentTranscriptCount ?? 0
if (session && session.messageCount === 0 && subagentTranscriptCount > 0) {
return { ...session, subagentTranscriptCount }
}
return session
} catch (err) {
issues.push({
executionHostId: context.executionHostId,
@@ -0,0 +1,146 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types'
const mocks = vi.hoisted(() => ({
callRuntimeEnvironment: vi.fn(),
listEnvironments: vi.fn()
}))
vi.mock('../../shared/runtime-environment-store', () => ({
listEnvironments: mocks.listEnvironments
}))
vi.mock('../ipc/runtime-environment-transport-routing', () => ({
callRuntimeEnvironment: mocks.callRuntimeEnvironment
}))
const { getSavedRuntimeAiVaultHostInfos, scanRuntimeAiVaultSessions } =
await import('./runtime-session-scanner')
beforeEach(() => {
vi.clearAllMocks()
mocks.listEnvironments.mockReturnValue([])
mocks.callRuntimeEnvironment.mockResolvedValue({
ok: true,
result: result([session('runtime:env-1', 'session-1')])
})
})
describe('runtime AI Vault session scanner', () => {
it('lists saved runtime environments as runtime execution hosts', () => {
mocks.listEnvironments.mockReturnValue([
{ id: 'env-1', name: 'Server 1' },
{ id: 'env-2', name: 'Server 2' }
])
expect(getSavedRuntimeAiVaultHostInfos('/user-data')).toEqual([
{ environmentId: 'env-1', executionHostId: 'runtime:env-1' },
{ environmentId: 'env-2', executionHostId: 'runtime:env-2' }
])
})
it('forwards scan options to the runtime transport', async () => {
await scanRuntimeAiVaultSessions(
'/user-data',
'env-1',
{
limit: 25,
force: true,
scopePaths: ['/srv/app']
},
{ timeoutMs: 3000 }
)
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith(
'/user-data',
'env-1',
'aiVault.listSessions',
{
limit: 25,
force: true,
scopePaths: ['/srv/app'],
executionHostId: 'runtime:env-1'
},
3000
)
})
it('stamps sessions and issues returned for a different execution host', async () => {
mocks.callRuntimeEnvironment.mockResolvedValueOnce({
ok: true,
result: result(
[session('local', 'session-1')],
[
{
executionHostId: 'ssh:dev-box',
agent: 'codex',
path: '/sessions/session-1.jsonl',
message: 'could not parse session'
}
]
)
})
const scanResult = await scanRuntimeAiVaultSessions('/user-data', 'env-1', {})
expect(scanResult.sessions).toEqual([
expect.objectContaining({
id: 'runtime:env-1:codex:session-1:/sessions/session-1.jsonl',
executionHostId: 'runtime:env-1'
})
])
expect(scanResult.issues).toEqual([
expect.objectContaining({
executionHostId: 'runtime:env-1',
agent: 'codex',
path: '/sessions/session-1.jsonl'
})
])
})
it('stamps accepted sessions with the requested runtime host', async () => {
const scanResult = await scanRuntimeAiVaultSessions('/user-data', 'env-1', {})
expect(scanResult.sessions).toEqual([
expect.objectContaining({
id: 'runtime:env-1:codex:session-1:/sessions/session-1.jsonl',
executionHostId: 'runtime:env-1'
})
])
})
})
function result(
sessions: AiVaultSession[],
issues: AiVaultListResult['issues'] = []
): AiVaultListResult {
return { sessions, issues, scannedAt: '2026-07-04T00:00:00.000Z' }
}
function session(
executionHostId: AiVaultSession['executionHostId'],
sessionId: string
): AiVaultSession {
return {
id: `${executionHostId}:codex:${sessionId}:/sessions/${sessionId}.jsonl`,
executionHostId,
executionHostPlatform: 'linux',
agent: 'codex',
sessionId,
title: sessionId,
cwd: '/srv/app',
branch: null,
model: null,
filePath: `/sessions/${sessionId}.jsonl`,
codexHome: null,
createdAt: null,
updatedAt: '2026-07-04T03:00:00.000Z',
modifiedAt: '2026-07-04T00:00:00.000Z',
messageCount: 1,
totalTokens: 0,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: `codex resume ${sessionId}`
}
}
@@ -0,0 +1,180 @@
import { z } from 'zod'
import {
AI_VAULT_AGENTS,
type AiVaultListArgs,
type AiVaultListResult,
type AiVaultSession
} from '../../shared/ai-vault-types'
import { normalizeExecutionHostId, toRuntimeExecutionHostId } from '../../shared/execution-host'
import { listEnvironments } from '../../shared/runtime-environment-store'
import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing'
export type RuntimeAiVaultHostInfo = {
environmentId: string
executionHostId: `runtime:${string}`
}
export type RuntimeAiVaultScanOptions = {
timeoutMs?: number
}
const nodePlatformSchema = z.enum([
'aix',
'android',
'darwin',
'freebsd',
'haiku',
'linux',
'openbsd',
'sunos',
'win32',
'cygwin',
'netbsd'
] satisfies NodeJS.Platform[])
const aiVaultSessionPreviewMessageSchema = z.object({
role: z.enum(['user', 'assistant', 'system', 'tool', 'unknown']),
text: z.string(),
timestamp: z.string().nullable()
})
const executionHostIdSchema = z.string().transform((value, ctx) => {
const normalized = normalizeExecutionHostId(value)
if (normalized) {
return normalized
}
ctx.addIssue({
code: 'custom',
message: 'Invalid execution host id'
})
return z.NEVER
})
const aiVaultListResultSchema = z.object({
sessions: z.array(
z.object({
id: z.string(),
executionHostId: executionHostIdSchema,
executionHostPlatform: nodePlatformSchema.nullable().optional(),
agent: z.enum(AI_VAULT_AGENTS),
sessionId: z.string(),
title: z.string(),
cwd: z.string().nullable(),
branch: z.string().nullable(),
model: z.string().nullable(),
filePath: z.string(),
codexHome: z.string().nullable(),
createdAt: z.string().nullable(),
updatedAt: z.string().nullable(),
modifiedAt: z.string(),
messageCount: z.number(),
totalTokens: z.number(),
previewMessages: z.array(aiVaultSessionPreviewMessageSchema),
// Default keeps remote hosts running an older build (no recoverable-signal
// fields) parseable; they simply report no recoverable-empty sessions.
queuedMessageCount: z.number().default(0),
subagentTranscriptCount: z.number().default(0),
resumeCommand: z.string()
})
),
issues: z.array(
z.object({
executionHostId: executionHostIdSchema.optional(),
agent: z.enum(AI_VAULT_AGENTS),
path: z.string(),
message: z.string()
})
),
scannedAt: z.string()
})
export function getSavedRuntimeAiVaultHostInfos(
userDataPath: string
): readonly RuntimeAiVaultHostInfo[] {
return listEnvironments(userDataPath).map((environment) => ({
environmentId: environment.id,
executionHostId: toRuntimeExecutionHostId(environment.id)
}))
}
export async function scanRuntimeAiVaultSessions(
userDataPath: string,
environmentId: string,
args: AiVaultListArgs,
options: RuntimeAiVaultScanOptions = {}
): Promise<AiVaultListResult> {
const executionHostId = toRuntimeExecutionHostId(environmentId)
const response = await callRuntimeEnvironment(
userDataPath,
environmentId,
'aiVault.listSessions',
{
limit: args.limit,
force: args.force,
scopePaths: args.scopePaths,
executionHostId
},
options.timeoutMs
)
if (response.ok === true) {
const parsed = aiVaultListResultSchema.safeParse(response.result)
if (parsed.success) {
return withRuntimeExecutionHost(parsed.data, executionHostId)
}
return runtimeScanIssueResult({
executionHostId,
environmentId,
message: `Invalid aiVault.listSessions response: ${
parsed.error.issues[0]?.message ?? 'unexpected result shape'
}`
})
}
return runtimeScanIssueResult({
executionHostId,
environmentId,
message: response.error.message
})
}
function withRuntimeExecutionHost(
result: AiVaultListResult,
executionHostId: `runtime:${string}`
): AiVaultListResult {
return {
sessions: result.sessions.map((session) => retagRuntimeSession(session, executionHostId)),
issues: result.issues.map((issue) => ({ ...issue, executionHostId })),
scannedAt: result.scannedAt
}
}
function retagRuntimeSession(
session: AiVaultSession,
executionHostId: `runtime:${string}`
): AiVaultSession {
// The paired server is the transport, but the parent owns which concrete
// runtime host was scanned; never trust returned host ids across the boundary.
return {
...session,
executionHostId,
id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}`
}
}
function runtimeScanIssueResult(args: {
executionHostId: `runtime:${string}`
environmentId: string
message: string
}): AiVaultListResult {
return {
sessions: [],
issues: [
{
executionHostId: args.executionHostId,
agent: 'codex',
path: args.environmentId,
message: args.message
}
],
scannedAt: new Date().toISOString()
}
}
+48
View File
@@ -0,0 +1,48 @@
import type {
AiVaultListResult,
AiVaultScanIssue,
AiVaultSession
} from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import { sessionSortTime } from './session-scanner-accumulator'
export function aiVaultScanIssueResult(args: {
executionHostId?: ExecutionHostId
path: string
message: string
}): AiVaultListResult {
return {
sessions: [],
issues: [
{
...(args.executionHostId ? { executionHostId: args.executionHostId } : {}),
agent: 'codex',
path: args.path,
message: args.message
}
],
scannedAt: new Date().toISOString()
}
}
export function mergeAiVaultListResults(
results: readonly AiVaultListResult[],
rawLimit: number | undefined
): AiVaultListResult {
const limit = rawLimit && rawLimit > 0 ? Math.floor(rawLimit) : 1000
const byId = new Map<string, AiVaultSession>()
const issues: AiVaultScanIssue[] = []
for (const result of results) {
for (const session of result.sessions) {
byId.set(session.id, session)
}
issues.push(...result.issues)
}
return {
sessions: [...byId.values()]
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
.slice(0, limit),
issues,
scannedAt: new Date().toISOString()
}
}
@@ -7,7 +7,11 @@ import {
type AiVaultSessionPreviewMessage
} from '../../shared/ai-vault-types'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
import type {
FileWithMtime,
ResumableSessionParseState,
SessionAccumulator
} from './session-scanner-types'
import {
extractPreviewContentText,
extractString,
@@ -37,10 +41,37 @@ export function createAccumulator(args: {
messageCount: 0,
totalTokens: 0,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
latestTimestampMs: 0
}
}
export function cloneSessionAccumulator(accumulator: SessionAccumulator): SessionAccumulator {
return { ...accumulator, previewMessages: [...accumulator.previewMessages] }
}
// Resumable fold for parsers whose only parse state is the accumulator itself
// (cursor, copilot, droid, openclaw/pi, gemini-jsonl). Parsers with extra
// closure state (claude, codex) build their own ResumableSessionParseState.
export function accumulatorFoldResumeState(
accumulator: SessionAccumulator,
consumeRecordLine: (accumulator: SessionAccumulator, line: string) => void
): ResumableSessionParseState {
return {
consumeLine: (line) => consumeRecordLine(accumulator, line),
clone: () =>
accumulatorFoldResumeState(cloneSessionAccumulator(accumulator), consumeRecordLine),
touchFile: (file) => {
accumulator.modifiedAt = file.modifiedAt
},
// Finalize a snapshot: the live accumulator (and its preview array) keeps
// accumulating appended lines after this session object is handed out.
finalize: (platform, options) =>
finalizeSession(cloneSessionAccumulator(accumulator), platform, options)
}
}
export function finalizeSession(
accumulator: SessionAccumulator,
platform: NodeJS.Platform,
@@ -81,9 +112,12 @@ export function finalizeSession(
messageCount: accumulator.messageCount,
totalTokens: accumulator.totalTokens,
previewMessages: accumulator.previewMessages,
queuedMessageCount: accumulator.queuedMessageCount,
subagentTranscriptCount: accumulator.subagentTranscriptCount,
resumeCommand: buildAiVaultResumeCommand({
agent: accumulator.agent,
sessionId,
resumeFilePath: accumulator.filePath,
cwd: accumulator.cwd,
platform,
codexHome: options.codexHome
@@ -6,7 +6,8 @@ import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-sc
import { parseKimiSessionFile } from './session-scanner-kimi-parser'
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
import { parseClaudeSessionFile, parseGeminiSessionFile } from './session-scanner-primary-parsers'
import { parseClaudeSessionFile } from './session-scanner-primary-parsers'
import { parseGeminiSessionFile } from './session-scanner-gemini-parsers'
import { parseCodexSessionFile } from './session-scanner-codex-parser'
import {
parseCopilotSessionFile,
@@ -64,6 +65,8 @@ export async function parseAgentSessionFile(
return parseMessageGraphSessionFile('openclaw', candidate.file, platform)
case 'pi':
return parseMessageGraphSessionFile('pi', candidate.file, platform)
case 'omp':
return parseMessageGraphSessionFile('omp', candidate.file, platform)
case 'droid':
return parseDroidSessionFile(candidate.file, platform)
case 'devin':
@@ -0,0 +1,92 @@
import type { IncrementalAgentFixture } from './session-scanner-incremental-fixtures'
// Codex fixture lines for the incremental-parse differential tests, split out
// of session-scanner-incremental-fixtures.ts to respect the max-lines budget.
const CODEX_SESSION_ID = '019f0000-1111-7222-8333-444444444444'
function codexLine(record: Record<string, unknown>): string {
return JSON.stringify(record)
}
export function codexFixture(): IncrementalAgentFixture {
return {
agent: 'codex',
fileName: `rollout-2026-05-01T10-00-00-${CODEX_SESSION_ID}.jsonl`,
seedLines: [
codexLine({
timestamp: '2026-05-01T10:00:00.000Z',
type: 'session_meta',
payload: { id: CODEX_SESSION_ID, cwd: '/repo/app', git: { branch: 'feature/vault' } }
}),
codexLine({
timestamp: '2026-05-01T10:00:05.000Z',
type: 'response_item',
payload: { type: 'message', role: 'user', content: 'codex seed question' }
}),
codexLine({
timestamp: '2026-05-01T10:00:10.000Z',
type: 'response_item',
payload: { type: 'message', role: 'assistant', content: 'codex seed answer' }
}),
codexLine({
timestamp: '2026-05-01T10:00:11.000Z',
type: 'event_msg',
payload: {
type: 'token_count',
info: { total_token_usage: { input_tokens: 100, output_tokens: 40, total_tokens: 140 } },
model: 'gpt-5.1-codex'
}
})
],
appendLines: [
codexLine({
timestamp: '2026-05-01T10:05:00.000Z',
type: 'event_msg',
payload: { type: 'user_message', message: 'codex follow-up' }
}),
codexLine({
timestamp: '2026-05-01T10:05:20.000Z',
type: 'event_msg',
payload: { type: 'agent_message', message: 'codex incremental answer' }
}),
codexLine({
timestamp: '2026-05-01T10:05:21.000Z',
type: 'event_msg',
payload: {
type: 'token_count',
info: { total_token_usage: { input_tokens: 220, output_tokens: 90, total_tokens: 310 } }
}
})
],
truncatedLines: [
codexLine({
timestamp: '2026-05-01T10:00:00.000Z',
type: 'session_meta',
payload: { id: CODEX_SESSION_ID, cwd: '/repo/app' }
}),
codexLine({
timestamp: '2026-05-01T10:00:05.000Z',
type: 'event_msg',
payload: { type: 'user_message', message: 'rewritten only turn' }
})
]
}
}
export function codexWorkerFixtureLines(): string[] {
return [
codexLine({
timestamp: '2026-05-01T10:00:00.000Z',
type: 'session_meta',
payload: { id: CODEX_SESSION_ID, cwd: '/repo/app', thread_source: 'subagent' }
}),
codexLine({
timestamp: '2026-05-01T10:00:05.000Z',
type: 'event_msg',
payload: { type: 'user_message', message: 'worker turn' }
})
]
}
export const CODEX_FIXTURE_SESSION_ID = CODEX_SESSION_ID
+224 -215
View File
@@ -1,18 +1,25 @@
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { readCodexSessionIndexTitle } from './session-scanner-codex-title-index'
import type { ExecutionHostId } from '../../shared/execution-host'
import {
addPreviewContent,
cloneSessionAccumulator,
createAccumulator,
finalizeSession,
sessionIdFromFileName,
updateTimeline
} from './session-scanner-accumulator'
import type { CodexUsageSnapshot, FileWithMtime } from './session-scanner-types'
import type {
CodexUsageSnapshot,
FileWithMtime,
ResumableParseFinalizeOptions,
ResumableSessionParseState,
SessionAccumulator
} from './session-scanner-types'
import {
addCodexUsage,
asRecord,
extractContentText,
extractGitBranch,
@@ -24,15 +31,6 @@ import {
subtractCodexUsage
} from './session-scanner-values'
const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl'
type CodexSessionIndexTitleCacheEntry = {
signature: string
titles: Map<string, string>
}
const codexSessionIndexTitleCache = new Map<string, Promise<CodexSessionIndexTitleCacheEntry>>()
export async function parseCodexSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform,
@@ -74,6 +72,213 @@ export async function parseCodexSessionContent(args: {
})
}
type CodexSessionParseState = {
accumulator: SessionAccumulator
previousTotals: CodexUsageSnapshot | null
rejectedWorkerSession: boolean
sawSessionMeta: boolean
// Which source set the current title; an index-file title outranks the raw
// first user prompt, so finalize must know whether 'meta' already won.
titleSource: 'meta' | 'user' | null
}
function createCodexParseState(file: FileWithMtime): CodexSessionParseState {
return {
accumulator: createAccumulator({
agent: 'codex',
file,
sessionId: sessionIdFromFileName(file.path)
}),
previousTotals: null,
rejectedWorkerSession: false,
sawSessionMeta: false,
titleSource: null
}
}
function cloneCodexParseState(state: CodexSessionParseState): CodexSessionParseState {
return {
// previousTotals snapshots are replaced, never mutated, so sharing is safe.
...state,
accumulator: cloneSessionAccumulator(state.accumulator)
}
}
function consumeCodexRecordLine(state: CodexSessionParseState, line: string): void {
if (state.rejectedWorkerSession) {
return
}
const record = parseJsonObject(line)
if (!record) {
return
}
const { accumulator } = state
updateTimeline(accumulator, extractString(record.timestamp))
const payload = asRecord(record.payload)
if (record.type === 'session_meta' && payload) {
if (isCodexWorkerSession(payload)) {
// Why: Codex writes internal worker/sub-agent transcripts into the same
// history tree; AI Vault should show user-started sessions only.
state.rejectedWorkerSession = true
return
}
state.sawSessionMeta = true
const sessionId = extractString(payload.id)
if (sessionId) {
accumulator.sessionId = sessionId
}
const metadataTitle = extractCodexSessionMetadataTitle(payload)
if (metadataTitle) {
accumulator.title = metadataTitle
state.titleSource = 'meta'
}
const cwd = extractString(payload.cwd)
if (cwd) {
accumulator.cwd = cwd
}
accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch
return
}
if (record.type === 'turn_context' && payload) {
const cwd = extractString(payload.cwd)
if (cwd) {
accumulator.cwd = cwd
}
const model = extractModel(payload)
if (model) {
accumulator.model = model
}
return
}
if (!payload) {
return
}
if (record.type === 'response_item' && payload.type === 'message') {
accumulator.messageCount++
if (payload.role === 'user' && !accumulator.title) {
accumulator.title = extractContentText(payload.content)
state.titleSource = accumulator.title ? 'user' : state.titleSource
}
addPreviewContent(
accumulator,
payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown',
payload.content,
record.timestamp
)
return
}
if (record.type !== 'event_msg') {
return
}
if (payload.type === 'user_message') {
accumulator.messageCount++
if (!accumulator.title) {
accumulator.title = extractContentText(payload.message)
state.titleSource = accumulator.title ? 'user' : state.titleSource
}
addPreviewContent(accumulator, 'user', payload.message, record.timestamp)
return
}
if (payload.type === 'agent_message') {
accumulator.messageCount++
addPreviewContent(accumulator, 'assistant', payload.message, record.timestamp)
return
}
if (payload.type !== 'token_count') {
return
}
const info = asRecord(payload.info)
if (!info) {
return
}
const totalUsage = normalizeCodexUsage(info.total_token_usage)
const lastUsage = normalizeCodexUsage(info.last_token_usage)
let delta: CodexUsageSnapshot | null = null
if (totalUsage) {
delta = subtractCodexUsage(totalUsage, state.previousTotals)
state.previousTotals = totalUsage
} else if (lastUsage) {
delta = lastUsage
state.previousTotals = state.previousTotals
? addCodexUsage(state.previousTotals, lastUsage)
: lastUsage
}
if (delta) {
accumulator.totalTokens += delta.totalTokens
}
const model = extractModel(payload)
if (model) {
accumulator.model = model
}
}
async function finalizeCodexParseState(
state: CodexSessionParseState,
platform: NodeJS.Platform,
args: {
codexHome: string | null
titleReader?: (sessionId: string) => Promise<string | null>
executionHostId?: ExecutionHostId
executionHostPlatform?: NodeJS.Platform | null
}
): Promise<AiVaultSession | null> {
if (state.rejectedWorkerSession) {
return null
}
// Finalize a snapshot: the live state keeps accumulating appended lines.
const snapshot = cloneCodexParseState(state)
// Why: Codex names threads lazily in session_index.jsonl, so the lookup runs
// per finalize (the index read is signature-cached) — a title that appears
// after the transcript was first parsed must still replace the raw prompt.
if (snapshot.sawSessionMeta && snapshot.titleSource !== 'meta') {
const indexedTitle = await args.titleReader?.(snapshot.accumulator.sessionId)
if (indexedTitle) {
snapshot.accumulator.title = indexedTitle
}
}
return finalizeSession(snapshot.accumulator, platform, {
codexHome: args.codexHome,
executionHostId: args.executionHostId,
executionHostPlatform: args.executionHostPlatform
})
}
export function createCodexSessionResumeState(
file: FileWithMtime,
codexHome: string | null
): ResumableSessionParseState {
return codexResumeStateFromParseState(createCodexParseState(file), codexHome, (sessionId) =>
readCodexSessionIndexTitle(file.path, codexHome, sessionId)
)
}
function codexResumeStateFromParseState(
state: CodexSessionParseState,
codexHome: string | null,
titleReader: (sessionId: string) => Promise<string | null>
): ResumableSessionParseState {
return {
consumeLine: (line) => consumeCodexRecordLine(state, line),
clone: () =>
codexResumeStateFromParseState(cloneCodexParseState(state), codexHome, titleReader),
touchFile: (file) => {
state.accumulator.modifiedAt = file.modifiedAt
},
finalize: (platform, options?: ResumableParseFinalizeOptions) =>
finalizeCodexParseState(state, platform, { codexHome, titleReader, ...options })
}
}
async function parseCodexSessionLines(args: {
file: FileWithMtime
lines: AsyncIterable<string> | Iterable<string>
@@ -83,142 +288,22 @@ async function parseCodexSessionLines(args: {
executionHostPlatform?: NodeJS.Platform | null
titleReader?: (sessionId: string) => Promise<string | null>
}): Promise<AiVaultSession | null> {
const accumulator = createAccumulator({
agent: 'codex',
file: args.file,
sessionId: sessionIdFromFileName(args.file.path)
})
let previousTotals: CodexUsageSnapshot | null = null
const state = createCodexParseState(args.file)
for await (const line of args.lines) {
const record = parseJsonObject(line)
if (!record) {
continue
}
updateTimeline(accumulator, extractString(record.timestamp))
const payload = asRecord(record.payload)
if (record.type === 'session_meta' && payload) {
if (isCodexWorkerSession(payload)) {
// Why: Codex writes internal worker/sub-agent transcripts into the same
// history tree; AI Vault should show user-started sessions only.
return null
}
const sessionId = extractString(payload.id)
if (sessionId) {
accumulator.sessionId = sessionId
}
const indexedTitle =
extractCodexSessionMetadataTitle(payload) ??
(await args.titleReader?.(accumulator.sessionId))
if (indexedTitle) {
accumulator.title = indexedTitle
}
const cwd = extractString(payload.cwd)
if (cwd) {
accumulator.cwd = cwd
}
accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch
continue
}
if (record.type === 'turn_context' && payload) {
const cwd = extractString(payload.cwd)
if (cwd) {
accumulator.cwd = cwd
}
const model = extractModel(payload)
if (model) {
accumulator.model = model
}
continue
}
if (!payload) {
continue
}
if (record.type === 'response_item' && payload.type === 'message') {
accumulator.messageCount++
if (payload.role === 'user' && !accumulator.title) {
accumulator.title = extractContentText(payload.content)
}
addPreviewContent(
accumulator,
payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown',
payload.content,
record.timestamp
)
continue
}
if (record.type !== 'event_msg') {
continue
}
if (payload.type === 'user_message') {
accumulator.messageCount++
if (!accumulator.title) {
accumulator.title = extractContentText(payload.message)
}
addPreviewContent(accumulator, 'user', payload.message, record.timestamp)
continue
}
if (payload.type === 'agent_message') {
accumulator.messageCount++
addPreviewContent(accumulator, 'assistant', payload.message, record.timestamp)
continue
}
if (payload.type !== 'token_count') {
continue
}
const info = asRecord(payload.info)
if (!info) {
continue
}
const totalUsage = normalizeCodexUsage(info.total_token_usage)
const lastUsage = normalizeCodexUsage(info.last_token_usage)
let delta: CodexUsageSnapshot | null = null
if (totalUsage) {
delta = subtractCodexUsage(totalUsage, previousTotals)
previousTotals = totalUsage
} else if (lastUsage) {
delta = lastUsage
previousTotals = previousTotals ? addCodexUsage(previousTotals, lastUsage) : lastUsage
}
if (delta) {
accumulator.totalTokens += delta.totalTokens
}
const model = extractModel(payload)
if (model) {
accumulator.model = model
consumeCodexRecordLine(state, line)
if (state.rejectedWorkerSession) {
// Worker transcripts are excluded outright; stop reading early.
return null
}
}
return finalizeSession(accumulator, args.platform, {
return finalizeCodexParseState(state, args.platform, {
codexHome: args.codexHome,
titleReader: args.titleReader,
executionHostId: args.executionHostId,
executionHostPlatform: args.executionHostPlatform
})
}
function addCodexUsage(
base: CodexUsageSnapshot,
increment: CodexUsageSnapshot
): CodexUsageSnapshot {
return {
inputTokens: base.inputTokens + increment.inputTokens,
cachedInputTokens: base.cachedInputTokens + increment.cachedInputTokens,
outputTokens: base.outputTokens + increment.outputTokens,
reasoningOutputTokens: base.reasoningOutputTokens + increment.reasoningOutputTokens,
totalTokens: base.totalTokens + increment.totalTokens
}
}
function extractCodexThreadSource(payload: Record<string, unknown>): string | null {
return extractString(payload.thread_source) ?? extractString(payload.threadSource)
}
@@ -240,79 +325,3 @@ function extractCodexSessionMetadataTitle(payload: Record<string, unknown>): str
normalizeTitleText(extractString(payload.threadName) ?? '')
)
}
async function readCodexSessionIndexTitle(
sessionFilePath: string,
codexHome: string | null,
sessionId: string
): Promise<string | null> {
const resolvedCodexHome = codexHome ?? codexHomeFromSessionFilePath(sessionFilePath)
if (!resolvedCodexHome) {
return null
}
const titleBySessionId = await readCodexSessionIndexTitles(resolvedCodexHome)
return titleBySessionId.get(sessionId) ?? null
}
function codexHomeFromSessionFilePath(sessionFilePath: string): string | null {
let currentDir = dirname(sessionFilePath)
while (currentDir && dirname(currentDir) !== currentDir) {
if (basename(currentDir) === 'sessions') {
return dirname(currentDir)
}
currentDir = dirname(currentDir)
}
return null
}
async function readCodexSessionIndexTitles(codexHome: string): Promise<Map<string, string>> {
const indexPath = join(codexHome, CODEX_SESSION_INDEX_FILE)
let signature: string
try {
const indexStat = await stat(indexPath)
signature = `${indexStat.size}:${indexStat.mtimeMs}`
} catch {
return new Map()
}
const cached = codexSessionIndexTitleCache.get(codexHome)
if (cached) {
const entry = await cached
if (entry.signature === signature) {
return entry.titles
}
}
const pending = readCodexSessionIndexTitlesFromDisk(indexPath).then((titles) => ({
signature,
titles
}))
codexSessionIndexTitleCache.set(codexHome, pending)
return (await pending).titles
}
async function readCodexSessionIndexTitlesFromDisk(
indexPath: string
): Promise<Map<string, string>> {
const titleBySessionId = new Map<string, string>()
try {
const lines = createInterface({
input: createReadStream(indexPath, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const line of lines) {
const record = parseJsonObject(line)
if (!record) {
continue
}
const sessionId = extractString(record.id)
const title = normalizeTitleText(extractString(record.thread_name) ?? '')
if (sessionId && title) {
titleBySessionId.set(sessionId, title)
}
}
} catch {
// Codex creates the index opportunistically; older homes may only have raw transcripts.
}
return titleBySessionId
}
@@ -0,0 +1,93 @@
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { createInterface } from 'node:readline'
import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values'
// Codex names threads lazily in <CODEX_HOME>/session_index.jsonl; transcripts
// carry no title of their own, so parsers look the thread name up here.
const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl'
type CodexSessionIndexTitleCacheEntry = {
signature: string
titles: Map<string, string>
}
const codexSessionIndexTitleCache = new Map<string, Promise<CodexSessionIndexTitleCacheEntry>>()
export async function readCodexSessionIndexTitle(
sessionFilePath: string,
codexHome: string | null,
sessionId: string
): Promise<string | null> {
const resolvedCodexHome = codexHome ?? codexHomeFromSessionFilePath(sessionFilePath)
if (!resolvedCodexHome) {
return null
}
const titleBySessionId = await readCodexSessionIndexTitles(resolvedCodexHome)
return titleBySessionId.get(sessionId) ?? null
}
function codexHomeFromSessionFilePath(sessionFilePath: string): string | null {
let currentDir = dirname(sessionFilePath)
while (currentDir && dirname(currentDir) !== currentDir) {
if (basename(currentDir) === 'sessions') {
return dirname(currentDir)
}
currentDir = dirname(currentDir)
}
return null
}
async function readCodexSessionIndexTitles(codexHome: string): Promise<Map<string, string>> {
const indexPath = join(codexHome, CODEX_SESSION_INDEX_FILE)
let signature: string
try {
const indexStat = await stat(indexPath)
signature = `${indexStat.size}:${indexStat.mtimeMs}`
} catch {
return new Map()
}
const cached = codexSessionIndexTitleCache.get(codexHome)
if (cached) {
const entry = await cached
if (entry.signature === signature) {
return entry.titles
}
}
const pending = readCodexSessionIndexTitlesFromDisk(indexPath).then((titles) => ({
signature,
titles
}))
codexSessionIndexTitleCache.set(codexHome, pending)
return (await pending).titles
}
async function readCodexSessionIndexTitlesFromDisk(
indexPath: string
): Promise<Map<string, string>> {
const titleBySessionId = new Map<string, string>()
try {
const lines = createInterface({
input: createReadStream(indexPath, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const line of lines) {
const record = parseJsonObject(line)
if (!record) {
continue
}
const sessionId = extractString(record.id)
const title = normalizeTitleText(extractString(record.thread_name) ?? '')
if (sessionId && title) {
titleBySessionId.set(sessionId, title)
}
}
} catch {
// Codex creates the index opportunistically; older homes may only have raw transcripts.
}
return titleBySessionId
}
@@ -117,6 +117,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => {
openclawStateDir: join(root, 'openclaw-state'),
openclawLegacyStateDir: join(root, 'openclaw-legacy-state'),
piSessionsDir: join(root, 'pi-sessions'),
ompSessionsDir: join(root, 'omp-sessions'),
droidSessionsDir: join(root, 'droid-sessions'),
droidProjectsDir: join(root, 'droid-projects'),
kimiSessionsDir: join(root, 'kimi-sessions'),
@@ -25,7 +25,8 @@ export async function discoverFiles(args: {
files.push({
path,
mtimeMs: fileStat.mtimeMs,
modifiedAt: fileStat.mtime.toISOString()
modifiedAt: fileStat.mtime.toISOString(),
sizeBytes: fileStat.size
})
} catch (err) {
args.issues.push({ agent: args.agent, path, message: errorMessage(err) })
@@ -2,11 +2,15 @@ import { createReadStream } from 'node:fs'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
import type {
FileWithMtime,
ResumableSessionParseState,
SessionAccumulator
} from './session-scanner-types'
import {
accumulatorFoldResumeState,
addPreviewMessage,
createAccumulator,
finalizeSession,
sessionIdFromFileName,
updateTimeline
} from './session-scanner-accumulator'
@@ -50,51 +54,57 @@ export async function parseDroidSessionContent(
})
}
function consumeDroidRecordLine(accumulator: SessionAccumulator, line: string): void {
const record = parseJsonObject(line)
if (!record) {
return
}
updateTimeline(accumulator, record.timestamp)
if (record.type === 'session_start') {
accumulator.sessionId = extractString(record.id) ?? accumulator.sessionId
accumulator.title = normalizeTitleText(extractString(record.title) ?? '')
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
return
}
if (record.type === 'system') {
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
accumulator.model = extractString(record.model) ?? accumulator.model
}
const streamSessionId = extractString(record.session_id) ?? extractString(record.sessionId)
if (streamSessionId) {
accumulator.sessionId = streamSessionId
}
if (record.type === 'message') {
consumeDroidMessage(accumulator, record)
} else if (record.type === 'completion') {
accumulator.messageCount++
accumulator.totalTokens += tokenTotal(record.usage)
addPreviewMessage(accumulator, {
role: 'assistant',
text: extractString(record.finalText),
timestamp: record.timestamp
})
}
}
export function createDroidSessionResumeState(file: FileWithMtime): ResumableSessionParseState {
return accumulatorFoldResumeState(
createAccumulator({ agent: 'droid', file, sessionId: sessionIdFromFileName(file.path) }),
consumeDroidRecordLine
)
}
async function parseDroidSessionLines(args: {
file: FileWithMtime
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
}): Promise<AiVaultSession | null> {
const accumulator = createAccumulator({
agent: 'droid',
file: args.file,
sessionId: sessionIdFromFileName(args.file.path)
})
const state = createDroidSessionResumeState(args.file)
for await (const line of args.lines) {
const record = parseJsonObject(line)
if (!record) {
continue
}
updateTimeline(accumulator, record.timestamp)
if (record.type === 'session_start') {
accumulator.sessionId = extractString(record.id) ?? accumulator.sessionId
accumulator.title = normalizeTitleText(extractString(record.title) ?? '')
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
continue
}
if (record.type === 'system') {
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
accumulator.model = extractString(record.model) ?? accumulator.model
}
const streamSessionId = extractString(record.session_id) ?? extractString(record.sessionId)
if (streamSessionId) {
accumulator.sessionId = streamSessionId
}
if (record.type === 'message') {
consumeDroidMessage(accumulator, record)
} else if (record.type === 'completion') {
accumulator.messageCount++
accumulator.totalTokens += tokenTotal(record.usage)
addPreviewMessage(accumulator, {
role: 'assistant',
text: extractString(record.finalText),
timestamp: record.timestamp
})
}
state.consumeLine(line)
}
return finalizeSession(accumulator, args.platform, args.options)
return state.finalize(args.platform, args.options)
}
function consumeDroidMessage(
@@ -0,0 +1,156 @@
import { createReadStream } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type {
FileWithMtime,
ResumableParseFinalizeOptions,
ResumableSessionParseState,
SessionAccumulator
} from './session-scanner-types'
import {
accumulatorFoldResumeState,
addPreviewContent,
createAccumulator,
finalizeSession,
sessionIdFromFileName,
updateTimeline
} from './session-scanner-accumulator'
import {
arrayValue,
asRecord,
extractContentText,
extractString,
parseJsonObject,
tokenTotal
} from './session-scanner-values'
export async function parseGeminiSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
if (file.path.endsWith('.jsonl')) {
return parseGeminiJsonlSessionFile(file, platform)
}
return parseGeminiJsonSessionContent(file, await readFile(file.path, 'utf-8'), platform)
}
export async function parseGeminiSessionContent(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform = process.platform,
options: ResumableParseFinalizeOptions = {}
): Promise<AiVaultSession | null> {
if (file.path.endsWith('.jsonl')) {
return parseGeminiJsonlSessionLines({
file,
lines: content.split(/\r?\n/),
platform,
options
})
}
return parseGeminiJsonSessionContent(file, content, platform, options)
}
function parseGeminiJsonSessionContent(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform,
options: ResumableParseFinalizeOptions = {}
): AiVaultSession | null {
const record = asRecord(JSON.parse(content) as unknown)
if (!record) {
return null
}
const accumulator = createAccumulator({
agent: 'gemini',
file,
sessionId: extractString(record.sessionId) ?? sessionIdFromFileName(file.path)
})
updateTimeline(accumulator, extractString(record.startTime))
updateTimeline(accumulator, extractString(record.lastUpdated))
for (const message of arrayValue(record.messages)) {
consumeGeminiMessage(accumulator, asRecord(message))
}
return finalizeSession(accumulator, platform, options)
}
export async function parseGeminiJsonlSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
return parseGeminiJsonlSessionLines({ file, lines, platform })
}
function consumeGeminiJsonlRecordLine(accumulator: SessionAccumulator, line: string): void {
const record = parseJsonObject(line)
if (!record) {
return
}
const setRecord = asRecord(record.$set)
if (setRecord) {
updateTimeline(accumulator, extractString(setRecord.lastUpdated))
return
}
const sessionId = extractString(record.sessionId)
if (sessionId) {
accumulator.sessionId = sessionId
}
updateTimeline(accumulator, extractString(record.startTime))
updateTimeline(accumulator, extractString(record.lastUpdated))
consumeGeminiMessage(accumulator, record)
}
// Resumable only for the JSONL log format; Gemini's legacy single-JSON
// session documents are rewritten in place and must be re-read whole.
export function createGeminiJsonlSessionResumeState(
file: FileWithMtime
): ResumableSessionParseState {
return accumulatorFoldResumeState(
createAccumulator({ agent: 'gemini', file, sessionId: sessionIdFromFileName(file.path) }),
consumeGeminiJsonlRecordLine
)
}
async function parseGeminiJsonlSessionLines(args: {
file: FileWithMtime
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ResumableParseFinalizeOptions
}): Promise<AiVaultSession | null> {
const state = createGeminiJsonlSessionResumeState(args.file)
for await (const line of args.lines) {
state.consumeLine(line)
}
return state.finalize(args.platform, args.options)
}
export function consumeGeminiMessage(
accumulator: SessionAccumulator,
record: Record<string, unknown> | null
): void {
if (!record) {
return
}
updateTimeline(accumulator, extractString(record.timestamp))
if (record.type === 'user') {
accumulator.messageCount++
accumulator.title ??= extractContentText(record.content)
addPreviewContent(accumulator, 'user', record.content, record.timestamp)
return
}
if (record.type === 'gemini') {
accumulator.messageCount++
addPreviewContent(accumulator, 'assistant', record.content, record.timestamp)
const model = extractString(record.model)
if (model) {
accumulator.model = model
}
accumulator.totalTokens += tokenTotal(record.tokens)
}
}
@@ -4,8 +4,13 @@ import { basename, dirname, join } from 'node:path'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
import type {
FileWithMtime,
ResumableSessionParseState,
SessionAccumulator
} from './session-scanner-types'
import {
accumulatorFoldResumeState,
addPreviewContent,
addPreviewMessage,
createAccumulator,
@@ -158,8 +163,12 @@ export function rovoPartsText(parts: unknown[], role: 'user' | 'assistant'): str
return extractContentText(textParts)
}
// Agents whose transcripts are append-only message-graph JSONL (session +
// model_change + message records). OMP is a Pi fork and shares the format.
export type MessageGraphAgent = 'openclaw' | 'pi' | 'omp'
export async function parseMessageGraphSessionFile(
agent: 'openclaw' | 'pi',
agent: MessageGraphAgent,
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
@@ -171,7 +180,7 @@ export async function parseMessageGraphSessionFile(
}
export async function parseMessageGraphSessionContent(
agent: 'openclaw' | 'pi',
agent: MessageGraphAgent,
file: FileWithMtime,
content: string,
platform: NodeJS.Platform = process.platform,
@@ -186,53 +195,64 @@ export async function parseMessageGraphSessionContent(
})
}
function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: string): void {
const record = parseJsonObject(line)
if (!record) {
return
}
updateTimeline(accumulator, extractString(record.timestamp))
if (record.type === 'session') {
const sessionId = extractString(record.id)
if (sessionId) {
accumulator.sessionId = sessionId
}
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
return
}
if (record.type === 'model_change') {
// Pi writes `modelId`; OMP writes `model`. Prefer either so an in-progress
// session shows its model before the first assistant reply lands.
accumulator.model =
extractString(record.modelId) ?? extractString(record.model) ?? accumulator.model
return
}
if (record.type !== 'message') {
return
}
const message = asRecord(record.message)
const role = extractString(message?.role)
if (role === 'user' || role === 'assistant') {
accumulator.messageCount++
if (role === 'user') {
accumulator.title ??= extractMessageText(message)
} else {
accumulator.model = extractString(message?.model) ?? accumulator.model
accumulator.totalTokens += tokenTotal(message?.usage)
}
addPreviewContent(accumulator, role, message?.content, record.timestamp)
}
}
export function createMessageGraphSessionResumeState(
agent: MessageGraphAgent,
file: FileWithMtime
): ResumableSessionParseState {
return accumulatorFoldResumeState(
createAccumulator({ agent, file, sessionId: sessionIdFromFileName(file.path) }),
consumeMessageGraphRecordLine
)
}
async function parseMessageGraphSessionLines(args: {
agent: 'openclaw' | 'pi'
agent: MessageGraphAgent
file: FileWithMtime
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
}): Promise<AiVaultSession | null> {
const accumulator = createAccumulator({
agent: args.agent,
file: args.file,
sessionId: sessionIdFromFileName(args.file.path)
})
const state = createMessageGraphSessionResumeState(args.agent, args.file)
for await (const line of args.lines) {
const record = parseJsonObject(line)
if (!record) {
continue
}
updateTimeline(accumulator, extractString(record.timestamp))
if (record.type === 'session') {
const sessionId = extractString(record.id)
if (sessionId) {
accumulator.sessionId = sessionId
}
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
continue
}
if (record.type === 'model_change') {
accumulator.model = extractString(record.modelId) ?? accumulator.model
continue
}
if (record.type !== 'message') {
continue
}
const message = asRecord(record.message)
const role = extractString(message?.role)
if (role === 'user' || role === 'assistant') {
accumulator.messageCount++
if (role === 'user') {
accumulator.title ??= extractMessageText(message)
} else {
accumulator.model = extractString(message?.model) ?? accumulator.model
accumulator.totalTokens += tokenTotal(message?.usage)
}
addPreviewContent(accumulator, role, message?.content, record.timestamp)
}
state.consumeLine(line)
}
return finalizeSession(accumulator, args.platform, args.options)
return state.finalize(args.platform, args.options)
}
@@ -0,0 +1,291 @@
import type { AiVaultAgent } from '../../shared/ai-vault-types'
import { codexFixture } from './session-scanner-codex-fixtures'
// Line builders for the incremental-parse differential tests: each agent gets
// a seed transcript, an appended continuation, and a truncated rewrite, all in
// that agent's real on-disk JSONL record shapes.
export type IncrementalAgentFixture = {
agent: AiVaultAgent
fileName: string
seedLines: string[]
appendLines: string[]
truncatedLines: string[]
}
export function cursorFixture(): IncrementalAgentFixture {
const line = (role: string, text: string, at: string) =>
JSON.stringify({ role, message: { content: text }, timestamp: at })
return {
agent: 'cursor',
fileName: 'agent-transcripts-aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl',
seedLines: [
line('user', 'cursor seed question', '2026-05-01T10:00:00.000Z'),
line('assistant', 'cursor seed answer', '2026-05-01T10:01:00.000Z')
],
appendLines: [
line('user', 'cursor follow-up', '2026-05-01T10:02:00.000Z'),
line('assistant', 'cursor incremental answer', '2026-05-01T10:03:00.000Z')
],
truncatedLines: [line('user', 'cursor rewritten', '2026-05-01T10:00:00.000Z')]
}
}
export function copilotFixture(): IncrementalAgentFixture {
const line = (type: string, data: Record<string, unknown>, at: string) =>
JSON.stringify({ type, data, timestamp: at })
return {
agent: 'copilot',
fileName: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl',
seedLines: [
line(
'session.start',
{ sessionId: 'copilot-session-1', startTime: '2026-05-01T10:00:00.000Z' },
'2026-05-01T10:00:00.000Z'
),
line('user.message', { content: 'copilot seed question' }, '2026-05-01T10:00:05.000Z'),
line('assistant.message', { content: 'copilot seed answer' }, '2026-05-01T10:00:30.000Z')
],
appendLines: [
line('user.message', { content: 'copilot follow-up' }, '2026-05-01T10:05:00.000Z'),
line(
'assistant.message',
{ content: 'copilot incremental answer' },
'2026-05-01T10:05:30.000Z'
),
line(
'session.shutdown',
{ currentModel: 'gpt-5.1', currentTokens: 340 },
'2026-05-01T10:06:00.000Z'
)
],
truncatedLines: [
line(
'session.start',
{ sessionId: 'copilot-session-1', startTime: '2026-05-01T10:00:00.000Z' },
'2026-05-01T10:00:00.000Z'
)
]
}
}
export function droidFixture(): IncrementalAgentFixture {
return {
agent: 'droid',
fileName: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl',
seedLines: [
JSON.stringify({
type: 'session_start',
id: 'droid-session-1',
title: 'Droid seed task',
cwd: '/repo/app',
timestamp: '2026-05-01T10:00:00.000Z'
}),
JSON.stringify({
type: 'message',
role: 'user',
text: 'droid seed question',
timestamp: '2026-05-01T10:00:05.000Z'
})
],
appendLines: [
JSON.stringify({
type: 'completion',
finalText: 'droid incremental answer',
usage: { input_tokens: 50, output_tokens: 25 },
timestamp: '2026-05-01T10:01:00.000Z'
})
],
truncatedLines: [
JSON.stringify({
type: 'session_start',
id: 'droid-session-1',
title: 'Droid rewritten',
timestamp: '2026-05-01T10:00:00.000Z'
})
]
}
}
export function openclawFixture(): IncrementalAgentFixture {
return {
agent: 'openclaw',
fileName: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl',
seedLines: [
JSON.stringify({
type: 'session',
id: 'openclaw-session-1',
cwd: '/repo/app',
timestamp: '2026-05-01T10:00:00.000Z'
}),
JSON.stringify({
type: 'message',
message: { role: 'user', content: 'openclaw seed question' },
timestamp: '2026-05-01T10:00:05.000Z'
})
],
appendLines: [
JSON.stringify({
type: 'message',
message: {
role: 'assistant',
content: 'openclaw incremental answer',
model: 'claw-1',
usage: { input_tokens: 40, output_tokens: 20 }
},
timestamp: '2026-05-01T10:01:00.000Z'
})
],
truncatedLines: [
JSON.stringify({
type: 'session',
id: 'openclaw-session-1',
timestamp: '2026-05-01T10:00:00.000Z'
})
]
}
}
// Pi shares OpenClaw's message-graph format and factory, but gets its own
// fixture so the registry's 'pi' branch is exercised explicitly.
export function piFixture(): IncrementalAgentFixture {
return {
agent: 'pi',
fileName: 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff.jsonl',
seedLines: [
JSON.stringify({
type: 'session',
id: 'pi-session-1',
cwd: '/repo/app',
timestamp: '2026-05-01T10:00:00.000Z'
}),
JSON.stringify({
type: 'message',
message: { role: 'user', content: 'pi seed question' },
timestamp: '2026-05-01T10:00:05.000Z'
})
],
appendLines: [
JSON.stringify({
type: 'model_change',
modelId: 'pi-2',
timestamp: '2026-05-01T10:00:30.000Z'
}),
JSON.stringify({
type: 'message',
message: {
role: 'assistant',
content: 'pi incremental answer',
usage: { input_tokens: 30, output_tokens: 10 }
},
timestamp: '2026-05-01T10:01:00.000Z'
})
],
truncatedLines: [
JSON.stringify({ type: 'session', id: 'pi-session-1', timestamp: '2026-05-01T10:00:00.000Z' })
]
}
}
// OMP is a Pi fork sharing the message-graph format, but keys the model on
// `model` (not Pi's `modelId`); its own fixture exercises the registry's 'omp'
// branch and that model-key difference. (The session `title` is included for
// realism only — the parser derives the title from the first user message.)
export function ompFixture(): IncrementalAgentFixture {
return {
agent: 'omp',
fileName: '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000.jsonl',
seedLines: [
JSON.stringify({
type: 'session',
version: 3,
id: 'cccccccc-dddd-4eee-8fff-000000000000',
cwd: '/repo/app',
title: 'OMP session title',
timestamp: '2026-05-01T10:00:00.000Z'
}),
JSON.stringify({
type: 'model_change',
model: 'gpt-5.4-mini',
timestamp: '2026-05-01T10:00:01.000Z'
}),
JSON.stringify({
type: 'message',
message: { role: 'user', content: [{ type: 'text', text: 'omp seed question' }] },
timestamp: '2026-05-01T10:00:05.000Z'
})
],
appendLines: [
JSON.stringify({
type: 'message',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'omp incremental answer' }],
model: 'gpt-5.4-mini',
usage: { input: 40, output: 20, totalTokens: 60 }
},
timestamp: '2026-05-01T10:01:00.000Z'
})
],
truncatedLines: [
JSON.stringify({
type: 'session',
id: 'cccccccc-dddd-4eee-8fff-000000000000',
timestamp: '2026-05-01T10:00:00.000Z'
})
]
}
}
export function geminiJsonlFixture(): IncrementalAgentFixture {
return {
agent: 'gemini',
fileName: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl',
seedLines: [
JSON.stringify({
sessionId: 'gemini-session-1',
startTime: '2026-05-01T10:00:00.000Z',
type: 'user',
content: 'gemini seed question',
timestamp: '2026-05-01T10:00:00.000Z'
}),
JSON.stringify({
type: 'gemini',
content: 'gemini seed answer',
model: 'gemini-3-pro',
tokens: { input: 80, output: 30 },
timestamp: '2026-05-01T10:00:30.000Z'
})
],
appendLines: [
JSON.stringify({
type: 'user',
content: 'gemini follow-up',
timestamp: '2026-05-01T10:01:00.000Z'
}),
JSON.stringify({ $set: { lastUpdated: '2026-05-01T10:01:05.000Z' } })
],
truncatedLines: [
JSON.stringify({
sessionId: 'gemini-session-1',
type: 'user',
content: 'gemini rewritten',
timestamp: '2026-05-01T10:00:00.000Z'
})
]
}
}
export function allIncrementalAgentFixtures(): IncrementalAgentFixture[] {
return [
codexFixture(),
cursorFixture(),
copilotFixture(),
droidFixture(),
openclawFixture(),
piFixture(),
ompFixture(),
geminiJsonlFixture()
]
}

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