Merge remote-tracking branch 'origin/main' into brennanb2025/codex-structured-mobile-write

This commit is contained in:
Brennan Benson
2026-08-10 22:03:55 -07:00
486 changed files with 25239 additions and 23667 deletions
+98
View File
@@ -0,0 +1,98 @@
name: Release Policy
on:
release:
types:
- published
- edited
permissions:
contents: write
concurrency:
group: release-policy
cancel-in-progress: false
jobs:
enforce:
if: github.repository == 'stablyai/orca'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Enforce release policy
uses: actions/github-script@v8
with:
script: |
const release = context.payload.release;
const tag = release.tag_name;
const author = release.author?.login;
const number = "(?:0|[1-9][0-9]*)";
const version = `${number}\\.${number}\\.${number}`;
const stableTag = new RegExp(`^v${version}$`);
const prereleaseTag = new RegExp(
`^(?:v${version}-rc\\.${number}(?:\\.[0-9A-Za-z]+)?|mobile(?:-android)?-v${version})$`,
);
const expectedPrerelease = prereleaseTag.test(tag);
const allowed = author === "github-actions[bot]" &&
(stableTag.test(tag) || expectedPrerelease);
const { owner, repo } = context.repo;
async function restoreLatestStable() {
const releases = await github.paginate(github.rest.repos.listReleases, {
owner,
repo,
per_page: 100,
});
const stable = releases
.filter((candidate) =>
!candidate.draft &&
!candidate.prerelease &&
candidate.author?.login === "github-actions[bot]" &&
stableTag.test(candidate.tag_name),
)
.sort((left, right) => {
const a = left.tag_name.slice(1).split(".").map(Number);
const b = right.tag_name.slice(1).split(".").map(Number);
return a.reduce((result, part, index) => result || part - b[index], 0);
})
.at(-1);
if (stable) {
await github.rest.repos.updateRelease({
owner,
repo,
release_id: stable.id,
make_latest: "true",
});
}
}
if (allowed) {
if (release.prerelease !== expectedPrerelease) {
await github.rest.repos.updateRelease({
owner,
repo,
release_id: release.id,
prerelease: expectedPrerelease,
make_latest: expectedPrerelease ? "false" : "legacy",
});
}
await restoreLatestStable();
return;
}
await github.rest.repos.updateRelease({
owner,
repo,
release_id: release.id,
draft: true,
prerelease: true,
make_latest: "false",
});
await restoreLatestStable();
await github.rest.repos.deleteRelease({ owner, repo, release_id: release.id });
try {
await github.rest.git.deleteRef({ owner, repo, ref: `tags/${tag}` });
} catch (error) {
if (error.status !== 404) throw error;
}
core.warning(`Deleted unauthorized release ${tag} created by ${author || "unknown"}.`);
+2 -2
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.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/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.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/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.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
- **Android:** [Download APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk)
---
+1 -1
View File
@@ -7,7 +7,7 @@
"count": 1
},
{
"filePath": "src/renderer/src/components/sidebar/WorktreeCard.tsx",
"filePath": "src/renderer/src/components/sidebar/worktree-card-meta-row.tsx",
"kind": "jsx-attribute:label",
"text": "sidebar",
"dynamic": false,
-3
View File
@@ -235,7 +235,6 @@ 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
@@ -270,7 +269,6 @@ 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
@@ -342,7 +340,6 @@ 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
+27 -3
View File
@@ -1969,7 +1969,7 @@
"invariant": "Close permanently removes the owned provider session, agent descendants, and resume authority even when no TerminalPane is mounted; a terminating id remains reserved through natural exit, duplicate callers await the same completion, and immediate teardown upgrades any graceful request without signalling a recycled PID or a descendant tree after root ownership is lost; process-table work is locale-stable, bounded, fresh for each post-start request, same-turn coalesced, and begins within the requesting caller's deadline, including bulk worktree cleanup; detach and park preserve ownership; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.",
"oracle": "Capture the exact PTY before parking, prove it remains listed while the view is absent, close through the product state boundary, and poll the provider inventory until that exact ID disappears; an agent-marked PTY's detached-pgid child is alive before close and absent afterward; unit tests keep a naturally exited id reserved without re-killing its PID or signalling its captured tree, upgrade pending and post-snapshot graceful kills to immediate, force ps into the C locale, coalesce each bounded bulk-shutdown batch, share duplicate teardown completion, coalesce 20 same-turn process-table requests, start one shared successor without waiting for the prior scan, terminate cyclic-looking traversal, retain the source scan's timestamp, bound both read phases, avoid ambiguous SIGKILL, and assert canonical owner dedupe, exact pane tombstones, chained detach transfer, and restart alias restoration.",
"commands": [
"pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
"pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/pty-descendant-termination.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/providers/local-pty-provider.test.ts src/main/runtime/worktree-teardown.test.ts",
"pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1",
"pnpm run test:e2e -- tests/e2e/agent-descendant-process-kill.spec.ts --workers=1"
@@ -1989,11 +1989,18 @@
"src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts",
"src/renderer/src/store/slices/terminal-tab-retirement.test.ts",
"src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts",
"tests/e2e/completed-worker-retirement-resume.unit.test.ts",
"src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
"tests/e2e/terminal-parked-close-retirement.spec.ts",
"tests/e2e/agent-descendant-process-kill.spec.ts"
],
"assertionRefs": [
{
"file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts",
"assertions": [
"explicit close revokes the exact live recovery record both before and after PTY exit removes the tab, while PTY exit alone preserves interrupted-session recovery"
]
},
{
"file": "tests/e2e/terminal-parked-close-retirement.spec.ts",
"assertions": [
@@ -2711,11 +2718,13 @@
"oracle": "Renderer-state tests assert provider-session ownership across preserved and queued panes. Main/provider contracts assert atomic attach-only adoption, stable host/worktree/tab/leaf identity, no fresh spawn on adoption, and safe paired-runtime cancellation. The Electron oracle creates inactive runtime-owned Codex and Setup PTYs plus an unrelated canary, seeds an exact resumable provider session, removes only the target renderer projections, and activates the workspace. It requires byte-stable handle, PTY, incarnation, tab, leaf, process PID, renderer graph, persisted binding, runtime id, graph epoch, and daemon PID across first mount and reload; PID-specific DOM keyboard I/O must remain live with one launch, zero resume argv, zero signals, zero interruption text, and no canary mutation.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts --reporter=verbose",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts src/renderer/src/lib/pane-manager/pane-fit.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts",
"pnpm exec electron-vite build --mode e2e && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/live-background-terminal-mount-authority.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
],
"testFiles": [
"src/renderer/src/lib/resume-sleeping-agent-session.test.ts",
"tests/e2e/completed-worker-retirement-resume.unit.test.ts",
"src/main/providers/local-pty-provider.test.ts",
"src/main/daemon/terminal-host.test.ts",
"src/main/daemon/daemon-pty-adapter.test.ts",
@@ -2727,6 +2736,13 @@
"tests/e2e/live-background-terminal-mount-authority.spec.ts"
],
"assertionRefs": [
{
"file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts",
"assertions": [
"completed and explicitly retired workers retain no provider-resume authority on first workspace activation",
"the original tab, leaf, PTY, provider session, release state, replacement count, queued resume argv, emitted restored banner, and unrelated canary remain exact across restart"
]
},
{
"file": "src/renderer/src/lib/resume-sleeping-agent-session.test.ts",
"assertions": [
@@ -7867,16 +7883,24 @@
"invariant": "A settled Dispatch may close only its one coordinator-created terminal lease. Explicit reuse, real user input, retain, identity or host change, ambiguity, and another resource for the same exact host/pane/process must fence closure. Output preservation and the requested-to-releasing transition are atomic, archives remain readable without the provider file, retries resume idempotently, and orchestration reset removes archive and authority state.",
"oracle": "Record release intent for a settled owner, attempt exact reuse before close, and require worker-start to fail with terminal_release_in_progress while the terminal stays open; then release the original owner exactly once. Race retain and real user input against a controlled archive promise and require no committed archive or close. Change host or process identity and inject duplicate resource evidence to require retention. Freeze a structured transcript, delete its source file, and require archived worker-read to return the same bounded redacted messages. Restart a pending mutation, reset orchestration state, and create 50 resources while asserting replay convergence, zero orphan rows, two-query worker listing, and no unrelated close.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot"
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts --reporter=verbose"
],
"testFiles": [
"src/main/runtime/rpc/methods/orchestration-worker-release.test.ts",
"src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts",
"src/main/runtime/rpc/orchestration-mutation-ledger.test.ts",
"src/main/runtime/orchestration/worker-transcript-read.test.ts",
"src/renderer/src/lib/worker-terminal-takeover-report.test.ts"
"src/renderer/src/lib/worker-terminal-takeover-report.test.ts",
"tests/e2e/completed-worker-retirement-resume.unit.test.ts"
],
"assertionRefs": [
{
"file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts",
"assertions": [
"an already-exited succeeded Dispatch reaches released while its late exact close revokes the missing tab's stale resume record"
]
},
{
"file": "src/main/runtime/rpc/methods/orchestration-worker-release.test.ts",
"assertions": [
@@ -1058,36 +1058,6 @@
"auto.components.feature.wall.EditorAnimatedVisual.e16479c1c5": {
"ko": "[data-slash-menu] [data-slash-row].slash-active { background: rgba(24,24,27,0.07); box-shadow: inset 0 0 0 1px rgba(24,24,27,0.06); } [data-md-active-line][data-role=\"active\"] { color: rgb(113 113 122); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; } [data-md-active-line][data-role=\"h1\"] { color: inherit; font-family: inherit; font-size: 18px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.2; margin-top: 6px; } [data-md-caret] { display: inline-block; width: 1.5px; height: 1em; background: currentColor; vertical-align: -2px; margin-left: 1px; animation: md-caret-blink 1.05s steps(1) infinite; } @keyframes md-caret-blink { 0%, 50% { opacity: 1 } 51%, 100% { opacity: 0 } } @keyframes md-block-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } } @keyframes md-cursor-ripple { 0% { transform: scale(0.4); opacity: 0.9; } 100% { transform: scale(1.4); opacity: 0; } } [data-clicking=\"1\"] [data-cursor-ripple] { animation: md-cursor-ripple 460ms ease-out forwards; }"
},
"auto.components.feature.wall.FeatureTourPreview.04d54d50ec": {
"ko": "orca · zsh"
},
"auto.components.feature.wall.FeatureTourPreview.304ad0dfc1": {
"ko": "생각 중..."
},
"auto.components.feature.wall.FeatureTourPreview.3822d8d14b": {
"ko": "fix/worktree-picker-truncates"
},
"auto.components.feature.wall.FeatureTourPreview.47f16ecf34": {
"ko": "여러 작업을 동시에 진행하세요. 각 워크스페이스는 브랜치, terminal, agent 활동을 함께 보관합니다."
},
"auto.components.feature.wall.FeatureTourPreview.5d6ee181b6": {
"ko": "워크스페이스를 열어 terminal로 돌아가고, 테스트와 로그, agents 용으로 패널을 나누세요."
},
"auto.components.feature.wall.FeatureTourPreview.6218a9014d": {
"ko": "pnpm playwright test"
},
"auto.components.feature.wall.FeatureTourPreview.6ed43cb0e0": {
"ko": "dashboard.spec.ts"
},
"auto.components.feature.wall.FeatureTourPreview.70aa182266": {
"ko": "목표를 넘겨두고 자리를 비우세요. 코디네이터 agent가 작업을 나누어 병렬 PR을 만듭니다."
},
"auto.components.feature.wall.FeatureTourPreview.f10c14dd9d": {
"ko": "탭을 오갈 필요 없이 GitHub 또는 Linear 백로그에서 선택하고 한 번의 클릭으로 워크스페이스를 시작하세요."
},
"auto.components.feature.wall.FeatureTourWorkspaceCard.9220794f6a": {
"ko": "작업 중"
},
"auto.components.feature.wall.FeatureWallSetupChecklist.713cc529a5": {
"ko": "마일스톤"
},
@@ -1502,12 +1472,6 @@
"auto.components.new.workspace.ProjectCombobox.search": {
"ko": "프로젝트 검색..."
},
"auto.components.new.workspace.ProjectHostSetupCombobox.empty": {
"ko": "이 프로젝트에 준비된 호스트가 없습니다."
},
"auto.components.new.workspace.ProjectHostSetupCombobox.placeholder": {
"ko": "호스트 선택"
},
"auto.components.notification.sound.options.0acd3d384e": {
"ko": "클랙"
},
@@ -1520,9 +1484,6 @@
"auto.components.notification.sound.options.86af8d938c": {
"ko": "봉 소리"
},
"auto.components.onboarding.AgentFeatureSetupStep.97dcdc010f": {
"ko": "기능 활성화"
},
"auto.components.onboarding.AgentStep.1eee1c7bd8": {
"ko": "PATH에서 agents를 찾지 못했습니다. 나중에 설치할 항목을 선택하거나 빈 terminal로 계속 진행하세요."
},
@@ -1538,12 +1499,6 @@
"auto.components.onboarding.AgentStep.yoloPermissionsTooltip": {
"ko": "중단을 줄이기 위해 agents 권한 확인을 건너뜁니다."
},
"auto.components.onboarding.FeatureSetupChecklist.01426f3a23": {
"ko": "Agents가 사이트를 탐색하고, 페이지를 검사하고, 브라우저 작업을 처리할 수 있습니다."
},
"auto.components.onboarding.FeatureSetupChecklist.c5292c409d": {
"ko": "Agents는 요청 시 앱 창을 검사하고 로컬 앱을 작동할 수 있습니다."
},
"auto.components.onboarding.IntegrationsStep.0b4a7d23ab": {
"ko": "로그인 중"
},
@@ -1556,18 +1511,6 @@
"auto.components.onboarding.OnboardingFlow.windowsTerminalSubtitle": {
"ko": "새 창에서 사용할 기본 셸과 terminal에서 오른쪽 클릭이 동작하는 방식을 선택하세요."
},
"auto.components.onboarding.RepoStep.466108ab89": {
"ko": "선택한 호스트에 존재하는 경로를 입력하세요."
},
"auto.components.onboarding.RepoStep.6558d50c69": {
"ko": "한 번에 여러 repos를 가져오고 싶으신가요? 상위 폴더를 선택하세요."
},
"auto.components.onboarding.RepoStep.c33b190ca3": {
"ko": "호스트 경로만"
},
"auto.components.onboarding.RepoStep.cf23006ba7": {
"ko": "선택한 호스트"
},
"auto.components.onboarding.ThemeStep.7ee9234e54": {
"ko": "Ghostty 설정을 찾았습니다."
},
@@ -4976,9 +4919,6 @@
"auto.components.ui.color.picker.faa855a582": {
"ko": "Hex"
},
"auto.components.ui.team.multi.combobox.18ec58881e": {
"ko": "팀 검색..."
},
"auto.components.workspace.cleanup.WorkspaceCleanupDialog.1b18868569": {
"ko": "리뷰 필요"
},
+19
View File
@@ -27,6 +27,12 @@ const checks = [
],
enabled: true
},
{
name: 'Linux snapshot renderer tests',
command: 'python3',
args: ['native/computer-use-linux/runtime_render_test.py'],
enabled: true
},
{
name: 'native provider argument guardrails',
run: verifyNativeArgumentGuardrails,
@@ -69,6 +75,19 @@ const checks = [
run: verifyWindowsProviderHandshake,
enabled: process.platform === 'win32'
},
{
name: 'Windows snapshot renderer tests',
command: 'powershell.exe',
args: [
'-NoLogo',
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-File',
'native/computer-use-windows/runtime-render.test.ps1'
],
enabled: process.platform === 'win32'
},
{
name: 'macOS helper app bundle and signature',
run: verifyMacOSHelperApp,
+1
View File
@@ -12,6 +12,7 @@
"../src/main/agent-hooks/local-agent-cli-presence.ts",
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
"../src/main/agent-hooks/managed-hook-script-refresh.ts",
"../src/main/amp/hook-service.ts",
"../src/main/antigravity/hook-service.ts",
"../src/main/claude/hook-settings.ts",
+2 -1
View File
@@ -16,7 +16,8 @@ export default defineConfig({
test: {
environment: 'node',
// Why: Node 26's undefined Web Storage globals prevent Vitest from installing happy-dom's.
execArgv: ['--no-experimental-webstorage'],
// Why --expose-gc: retention tests need a deterministic collection point to measure what a queue really holds.
execArgv: ['--no-experimental-webstorage', '--expose-gc'],
// Why: happy-dom drops MutationObserver callbacks on GC; keep them alive like a browser does.
setupFiles: [resolve('config/scripts/happy-dom-mutation-observer-retention.ts')],
include: [
+2 -2
View File
@@ -110,7 +110,7 @@ Don't add a fourth level. If something needs more emphasis than "floating," you'
Use the shadcn primitives in `src/renderer/src/components/ui/` before writing anything custom. The shadcn-style wrappers in this folder follow a consistent pattern:
- Most carry a `data-slot="<name>"` attribute on their root for CSS targeting — do not strip it. (The non-shadcn helpers in this folder — `sonner`, `repo-multi-combobox`, `team-multi-combobox` — don't follow this pattern and shouldn't be modeled when adding new primitives that should.)
- Most carry a `data-slot="<name>"` attribute on their root for CSS targeting — do not strip it. (The non-shadcn helpers in this folder — `sonner` and `repo-multi-combobox` — don't follow this pattern and shouldn't be modeled when adding new primitives that should.)
- Use `cn()` for class merging. Pass user `className` last so callers can override.
- Use `class-variance-authority` (CVA) for variants when there are multiple.
@@ -148,7 +148,7 @@ When a control has multiple plausible primitives, use this fork:
| Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered |
| Single choice from a known list | `Select` | Custom listbox |
| Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) |
| Multi-select with search | `repo-multi-combobox` / `team-multi-combobox` (mirror their pattern) | Roll a new one |
| Multi-select with search | `repo-multi-combobox` (mirror its pattern) | Roll a new one |
| Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner |
| Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) |
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 17m">
<title>downloads: 17m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 18m">
<title>downloads: 18m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">17m</text>
<text x="90" y="14">17m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">18m</text>
<text x="90" y="14">18m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

-6
View File
@@ -93,12 +93,6 @@
"max-lines": ["error", { "max": 531, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/terminal-settings.tsx"],
"rules": {
"max-lines": ["error", { "max": 514, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["scripts/mock-server.ts"],
"rules": {
+5 -3
View File
@@ -115,13 +115,15 @@ import {
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
import { triggerMediumImpact } from '../../../src/platform/haptics'
import {
type GitHubProjectSortDirection,
type GitHubProjectTable as SharedGitHubProjectTable,
groupRows,
isIterationCurrent,
sortRows,
type ProjectGroup
} from '../../../src/tasks/mobile-github-project-group-sort'
} from '../../../../src/shared/github-project-group-sort'
import type {
GitHubProjectSortDirection,
GitHubProjectTable as SharedGitHubProjectTable
} from '../../../../src/shared/github-project-types'
import {
CROSS_REPO_DISPLAY_LIMIT,
isGitHubWorkItemsSshRemoteRequiredError,
+3 -91
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { View, Text, StyleSheet, Pressable, Switch } from 'react-native'
import { View, Text, Pressable, Switch } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import Animated, {
@@ -9,7 +9,7 @@ import Animated, {
} from 'react-native-reanimated'
import { useRouter } from 'expo-router'
import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
import { colors, spacing } from '../src/theme/mobile-theme'
import { loadHosts } from '../src/transport/host-store'
import type { HostProfile } from '../src/transport/types'
import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections'
@@ -17,6 +17,7 @@ import type { RpcClient } from '../src/transport/rpc-client'
import { PickerModal, type PickerOption } from '../src/components/PickerModal'
import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings'
import { setTerminalAutoRestoreFitMsForHost } from '../src/terminal/terminal-auto-restore-fit-state'
import { terminalSettingsScreenStyles as styles } from '../src/terminal/terminal-settings-screen-styles'
import {
loadTerminalAutocompleteEnabled,
loadTerminalTextScale,
@@ -380,92 +381,3 @@ export default function TerminalSettingsScreen() {
</GestureHandlerRootView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
paddingHorizontal: spacing.lg,
paddingTop: 0
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: spacing.sm,
marginBottom: spacing.lg
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.sm
},
heading: {
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary
},
scrollContent: {
paddingBottom: spacing.xl
},
groupHeading: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
letterSpacing: 0.5,
marginBottom: spacing.xs,
paddingHorizontal: spacing.xs
},
groupDescription: {
fontSize: typography.bodySize - 1,
color: colors.textSecondary,
lineHeight: 20,
paddingHorizontal: spacing.xs
},
section: {
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
overflow: 'hidden'
},
sectionTopGap: {
marginTop: spacing.sm
},
inputGroupGap: {
marginTop: spacing.xl
},
emptyText: {
fontSize: typography.bodySize,
color: colors.textSecondary,
padding: spacing.md
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
rowContent: {
flex: 1
},
rowLabel: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
rowSublabel: {
fontSize: typography.bodySize - 2,
color: colors.textSecondary,
marginTop: 2
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
}
})
@@ -1,170 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
import { Check, ChevronDown } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { RpcClient } from '../transport/rpc-client'
import { searchBaseRefs } from '../source-control/mobile-base-ref-search'
type Props = {
client: RpcClient | null
worktreeId: string
value: string
onChange: (ref: string) => void
editable?: boolean
}
// Base-branch field for the create-PR composer: a free-text input that also searches
// repo refs (debounced) and offers matches to tap — the RN analogue of desktop's
// base-ref combobox. Free text stays valid so an SSH-only / unmatched ref can still
// be entered.
export function MobilePrBasePicker({
client,
worktreeId,
value,
onChange,
editable = true
}: Props) {
const [results, setResults] = useState<string[]>([])
const [focused, setFocused] = useState(false)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
// Guards: drop results after unmount, and ignore an earlier search whose response
// arrives after a later one (out-of-order network) so stale matches can't clobber.
const mounted = useRef(true)
const seq = useRef(0)
useEffect(() => {
return () => {
mounted.current = false
if (timer.current) {
clearTimeout(timer.current)
}
}
}, [])
const queryRefs = useCallback(
(query: string) => {
if (timer.current) {
clearTimeout(timer.current)
}
if (!client || query.trim().length === 0) {
// Advance the generation so an earlier in-flight search can't land and
// repopulate results after the input was cleared.
seq.current += 1
setResults([])
return
}
timer.current = setTimeout(() => {
const requestSeq = ++seq.current
void searchBaseRefs(client, worktreeId, query.trim())
.then((refs) => {
if (!mounted.current || requestSeq !== seq.current) {
return
}
setResults(refs.filter((r) => r !== query).slice(0, 6))
})
// Why: a rejected ref search must not escape as an unhandled rejection;
// drop to an empty result set (free text stays valid to submit).
.catch(() => {
if (mounted.current && requestSeq === seq.current) {
setResults([])
}
})
}, 200)
},
[client, worktreeId]
)
return (
<View>
<View style={[styles.inputShell, !editable && styles.inputShellDisabled]}>
<TextInput
style={styles.input}
value={value}
onChangeText={(text) => {
onChange(text)
queryRefs(text)
}}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
placeholder="main"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
editable={editable}
/>
<ChevronDown size={14} color={colors.textSecondary} strokeWidth={2.2} />
</View>
{focused && results.length > 0 ? (
<View style={styles.results}>
{results.map((ref) => (
<Pressable
key={ref}
style={({ pressed }) => [styles.resultRow, pressed && styles.resultRowPressed]}
onPress={() => {
onChange(ref)
setResults([])
}}
>
<Text style={styles.resultText} numberOfLines={1}>
{ref}
</Text>
{ref === value ? (
<Check size={14} color={colors.textPrimary} strokeWidth={2.2} />
) : null}
</Pressable>
))}
</View>
) : null}
</View>
)
}
const styles = StyleSheet.create({
inputShell: {
minHeight: 40,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs
},
inputShellDisabled: {
opacity: 0.6
},
input: {
flex: 1,
minWidth: 0,
padding: 0,
color: colors.textPrimary,
fontSize: typography.bodySize,
fontFamily: typography.monoFamily
},
results: {
marginTop: spacing.xs,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
borderRadius: radii.input,
backgroundColor: colors.bgPanel,
overflow: 'hidden'
},
resultRow: {
minHeight: 40,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm,
paddingHorizontal: spacing.md,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle
},
resultRowPressed: { backgroundColor: colors.bgRaised },
resultText: {
flex: 1,
minWidth: 0,
color: colors.textPrimary,
fontSize: typography.bodySize,
fontFamily: typography.monoFamily
}
})
+1 -2
View File
@@ -149,8 +149,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
// Why: bgRaised lifts the field off bgBase/bgPanel so search is an obvious
// control, matching TextInputModal / MobilePrBasePicker input shells.
// Keep search visually distinct from surrounding panels.
backgroundColor: colors.bgRaised,
borderWidth: 1,
borderColor: colors.borderSubtle,
@@ -1,289 +0,0 @@
import { useCallback, useState } from 'react'
import { ActivityIndicator, Pressable, Switch, Text, TextInput, View } from 'react-native'
import {
ArrowRight,
GitMerge,
GitPullRequestArrow,
Sparkles,
TriangleAlert,
X
} from 'lucide-react-native'
import { colors } from '../../theme/mobile-theme'
import type { RpcClient } from '../../transport/rpc-client'
import type { RpcSuccess } from '../../transport/types'
import { triggerError, triggerSuccess } from '../../platform/haptics'
import {
createMobilePr,
getMobilePrCreateSuccessWarning,
shouldPushBeforeMobilePrCreate,
type MobilePrPrefill
} from '../../source-control/mobile-pr-create'
import { hostedReviewCopy } from '../../source-control/hosted-review-copy'
import {
getPrComposeDisabledReason,
isBaseHeadDistinct
} from '../../source-control/pr-compose-validation'
import { MobilePrBasePicker } from '../MobilePrBasePicker'
import { mobilePrComposeFormStyles as styles } from './mobile-pr-compose-form-styles'
export type PrComposePrefill = MobilePrPrefill
type Props = {
client: RpcClient | null
worktreeId: string
prefill: PrComposePrefill
// Head branch — enables the base≠head guard and the "from <branch>" hint.
head?: string | null
onCancel: () => void
onCreated: (url: string, warning?: string) => void
}
// A plain View keeps the compose form usable inside the PR sidebar ScrollView.
export function MobilePrComposeForm({
client,
worktreeId,
prefill,
head,
onCancel,
onCreated
}: Props) {
const copy = hostedReviewCopy(prefill.provider)
const ReviewIcon = prefill.provider === 'gitlab' ? GitMerge : GitPullRequestArrow
const [title, setTitle] = useState(prefill.title)
const [body, setBody] = useState(prefill.body)
const [base, setBase] = useState(prefill.base)
const [draft, setDraft] = useState(false)
const [generating, setGenerating] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const pushBeforeCreate = shouldPushBeforeMobilePrCreate(prefill)
const generate = useCallback(async () => {
if (!client || generating) {
return
}
setGenerating(true)
setError(null)
try {
const response = await client.sendRequest('git.generatePullRequestFields', {
worktree: `id:${worktreeId}`,
base,
title,
body,
draft
})
if (!response.ok) {
setError(response.error?.message || 'Failed to generate PR fields')
return
}
const result = (response as RpcSuccess).result as {
success?: boolean
fields?: { base: string; title: string; body: string; draft: boolean }
error?: string
}
if (result.success && result.fields) {
setBase(result.fields.base || base)
setTitle(result.fields.title || title)
setBody(result.fields.body || body)
setDraft(result.fields.draft)
} else if (result.error) {
setError(result.error)
}
} catch (err) {
triggerError()
setError(err instanceof Error ? err.message : 'Failed to generate PR fields')
} finally {
setGenerating(false)
}
}, [base, body, client, draft, generating, title, worktreeId])
const headRef = head ?? ''
const baseConflict = base.trim().length > 0 && !isBaseHeadDistinct(base, headRef)
const submitDisabledReason = getPrComposeDisabledReason({
title,
base,
head: headRef,
generating,
reviewLabel: copy.reviewLabel
})
const canSubmit = submitDisabledReason === null
const fieldsLocked = submitting || generating
const submit = useCallback(async () => {
if (!client || submitting || !canSubmit) {
return
}
setSubmitting(true)
setError(null)
try {
const outcome = await createMobilePr(client, worktreeId, {
provider: prefill.provider,
base,
// Send the same head the submit guard validated against, so the PR opens
// from the validated branch instead of a host-inferred one.
...(head ? { head } : {}),
title,
body,
draft,
pushBeforeCreate
})
if (outcome.ok) {
triggerSuccess()
const warning = getMobilePrCreateSuccessWarning(outcome, prefill.provider)
if (warning) {
setError(warning)
}
onCreated(outcome.url, warning)
} else {
triggerError()
setError(outcome.error)
}
} finally {
setSubmitting(false)
}
}, [
base,
body,
canSubmit,
client,
copy.titleLabel,
draft,
head,
onCreated,
prefill.provider,
pushBeforeCreate,
submitting,
title,
worktreeId
])
return (
<View style={styles.root}>
<View style={styles.headingRow}>
<View style={styles.headingTitle}>
<ReviewIcon size={14} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.heading}>New {copy.reviewLabel}</Text>
</View>
<View style={styles.headingActions}>
<Pressable
style={({ pressed }) => [styles.genButton, pressed && styles.genButtonPressed]}
disabled={generating || submitting}
onPress={() => void generate()}
accessibilityRole="button"
accessibilityLabel={`Generate ${copy.reviewLabel} details with AI`}
>
{generating ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<Sparkles size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
<Text style={styles.genButtonText}>{generating ? 'Generating…' : 'Generate'}</Text>
</Pressable>
<Pressable
style={styles.iconButton}
onPress={onCancel}
disabled={submitting}
accessibilityRole="button"
accessibilityLabel="Cancel"
hitSlop={8}
>
<X size={16} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
</View>
</View>
{head ? (
<View style={styles.branchFlow}>
<Text style={styles.branchToken} numberOfLines={1}>
{head}
</Text>
<ArrowRight size={12} color={colors.textSecondary} strokeWidth={2.2} />
<Text
style={[styles.branchToken, baseConflict && styles.branchTokenError]}
numberOfLines={1}
>
{base || 'base'}
</Text>
</View>
) : null}
<View style={styles.fieldStack}>
<TextInput
style={styles.titleInput}
value={title}
onChangeText={setTitle}
placeholder="Title"
placeholderTextColor={colors.textMuted}
editable={!fieldsLocked}
accessibilityLabel={`${copy.titleLabel} title`}
/>
<TextInput
style={styles.bodyInput}
value={body}
onChangeText={setBody}
placeholder="Description (optional)"
placeholderTextColor={colors.textMuted}
multiline
editable={!fieldsLocked}
accessibilityLabel={`${copy.titleLabel} description`}
/>
</View>
{generating ? (
<View style={styles.notice}>
<Sparkles size={13} color={colors.textSecondary} strokeWidth={2.1} />
<Text style={styles.noticeText}>Generating title and description…</Text>
</View>
) : null}
<View style={styles.baseRow}>
<Text style={styles.baseLabel}>Base</Text>
<View style={styles.baseControl}>
<MobilePrBasePicker
client={client}
worktreeId={worktreeId}
value={base}
onChange={setBase}
editable={!fieldsLocked}
/>
</View>
</View>
<View style={styles.draftRow}>
<Text style={styles.draftText}>Create as draft</Text>
<Switch value={draft} onValueChange={setDraft} disabled={fieldsLocked} />
</View>
{error || submitDisabledReason ? (
<View style={styles.notice}>
<TriangleAlert size={13} color={colors.statusRed} strokeWidth={2.1} />
<Text style={[styles.noticeText, styles.errorText]}>{error ?? submitDisabledReason}</Text>
</View>
) : null}
<Pressable
style={({ pressed }) => [
styles.submit,
(submitting || !canSubmit) && styles.submitDisabled,
pressed && styles.submitPressed
]}
disabled={submitting || !canSubmit}
onPress={() => void submit()}
accessibilityRole="button"
>
{submitting ? (
<ActivityIndicator size="small" color={colors.bgBase} />
) : (
<ReviewIcon size={14} color={colors.bgBase} strokeWidth={2.2} />
)}
<Text style={styles.submitText}>
{pushBeforeCreate
? draft
? `Push & create draft ${copy.shortLabel}`
: `Push & create ${copy.shortLabel}`
: draft
? `Create draft ${copy.shortLabel}`
: `Create ${copy.shortLabel}`}
</Text>
</Pressable>
</View>
)
}
@@ -7,7 +7,7 @@ import { canEditComment, isResolvableComment } from '../../session/pr-comment-ac
import { ConfirmModal } from '../ConfirmModal'
import { CommentMarkdown } from './CommentMarkdown'
import { PRCommentComposer } from './PRCommentComposer'
import { formatPrCommentRelativeTime } from './pr-comment-time'
import { formatPrCommentRelativeTime } from '../../../../src/shared/pr-comment-time'
import { prCommentsStyles as styles } from './pr-comments-styles'
export type PRCommentRepoSlug = { owner: string; repo: string; host?: string }
@@ -12,12 +12,14 @@ import { CommentMarkdown } from './CommentMarkdown'
import { PRCommentCard, type PRCommentCardActions } from './PRCommentCard'
import { PRCommentComposer } from './PRCommentComposer'
import {
PR_COMMENT_AUDIENCE_FILTERS,
filterPRCommentsByAudience,
getPRCommentAudienceCounts,
getPRCommentAudienceEmptyLabel,
type PRCommentAudienceFilter
} from './pr-comment-audience'
} from '../../../../src/shared/pr-comment-audience'
import {
PR_COMMENT_AUDIENCE_FILTERS,
getPRCommentAudienceEmptyLabel
} from './pr-comment-audience-labels'
import {
getPRCommentGroupCount,
getPRCommentGroupId,
@@ -25,7 +27,7 @@ import {
groupPRComments,
isResolvedPRCommentGroup,
type PRCommentGroup
} from './pr-comment-groups'
} from '../../../../src/shared/pr-comment-groups'
import { prCommentsStyles as styles } from './pr-comments-styles'
import { mobilePrSidebarStyles as shared } from './mobile-pr-sidebar-styles'
@@ -1,165 +0,0 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
export const mobilePrComposeFormStyles = StyleSheet.create({
root: {
gap: spacing.sm
},
headingRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm,
marginBottom: spacing.xs
},
headingTitle: {
flex: 1,
minWidth: 0,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs
},
heading: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '700'
},
headingActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs
},
genButton: {
minHeight: 32,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.sm,
borderRadius: radii.button,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
genButtonPressed: {
opacity: 0.7
},
genButtonText: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontWeight: '700'
},
iconButton: {
minWidth: 32,
minHeight: 32,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button
},
branchFlow: {
minHeight: 28,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs
},
branchToken: {
maxWidth: 116,
color: colors.textPrimary,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily
},
branchTokenError: {
color: colors.statusRed
},
fieldStack: {
gap: spacing.sm
},
titleInput: {
minHeight: 40,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '600'
},
bodyInput: {
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
color: colors.textPrimary,
fontSize: typography.bodySize,
// Why: a moderate fixed height avoids over-expanding inside the sidebar scroll.
minHeight: 120,
textAlignVertical: 'top'
},
baseRow: {
minHeight: 40,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
baseLabel: {
color: colors.textSecondary,
fontSize: typography.metaSize,
width: 36
},
baseControl: {
flex: 1,
minWidth: 0
},
draftRow: {
minHeight: 36,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
paddingHorizontal: spacing.sm
},
draftText: {
color: colors.textPrimary,
fontSize: typography.metaSize,
fontWeight: '700'
},
notice: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.xs
},
noticeText: {
flex: 1,
color: colors.textSecondary,
fontSize: typography.metaSize,
lineHeight: 18
},
errorText: {
color: colors.statusRed
},
submit: {
marginTop: spacing.xs,
minHeight: 44,
borderRadius: radii.button,
backgroundColor: colors.textPrimary,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs
},
submitDisabled: {
opacity: 0.45
},
submitPressed: {
opacity: 0.8
},
submitText: {
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '700'
}
})
@@ -0,0 +1,18 @@
import type { PRCommentAudienceFilter } from '../../../../src/shared/pr-comment-audience'
export const PR_COMMENT_AUDIENCE_FILTERS = [
{ value: 'all', label: 'All' },
{ value: 'human', label: 'Humans' },
{ value: 'bot', label: 'Bots' }
] satisfies { value: PRCommentAudienceFilter; label: string }[]
export function getPRCommentAudienceEmptyLabel(filter: PRCommentAudienceFilter): string {
switch (filter) {
case 'bot':
return 'No bot comments.'
case 'human':
return 'No human comments.'
case 'all':
return 'No comments yet.'
}
}
@@ -1,68 +0,0 @@
import type { PRComment } from '../../../../src/shared/types'
// Thread grouping for the PR comment timeline, ported from the desktop helper
// (src/renderer/src/lib/pr-comment-groups.ts) minus the DOM class constants.
// Inline review comments sharing a threadId collapse into a root + replies; all
// other comments are standalone. Upstream order is preserved.
export type PRCommentGroup =
| { kind: 'standalone'; comment: PRComment }
| { kind: 'thread'; threadId: string; root: PRComment; replies: PRComment[] }
export function groupPRComments(comments: PRComment[]): PRCommentGroup[] {
const threadMap = new Map<string, { root: PRComment; replies: PRComment[] }>()
const groupsByFirstComment = new Map<PRComment, PRCommentGroup>()
for (const comment of comments) {
if (!comment.threadId) {
groupsByFirstComment.set(comment, { kind: 'standalone', comment })
continue
}
const existing = threadMap.get(comment.threadId)
if (existing) {
existing.replies.push(comment)
continue
}
threadMap.set(comment.threadId, { root: comment, replies: [] })
}
const emitted = new Set<string>()
const groups: PRCommentGroup[] = []
for (const comment of comments) {
if (!comment.threadId) {
const group = groupsByFirstComment.get(comment)
if (group) {
groups.push(group)
}
continue
}
if (emitted.has(comment.threadId)) {
continue
}
emitted.add(comment.threadId)
const thread = threadMap.get(comment.threadId)
if (thread) {
groups.push({ kind: 'thread', threadId: comment.threadId, ...thread })
}
}
return groups
}
export function getPRCommentGroupComments(group: PRCommentGroup): PRComment[] {
return group.kind === 'thread' ? [group.root, ...group.replies] : [group.comment]
}
export function getPRCommentGroupRoot(group: PRCommentGroup): PRComment {
return group.kind === 'thread' ? group.root : group.comment
}
export function getPRCommentGroupCount(group: PRCommentGroup): number {
return getPRCommentGroupComments(group).length
}
export function isResolvedPRCommentGroup(group: PRCommentGroup): boolean {
return getPRCommentGroupRoot(group).isResolved === true
}
export function getPRCommentGroupId(group: PRCommentGroup): string {
return group.kind === 'thread' ? `thread:${group.threadId}` : `comment:${group.comment.id}`
}
@@ -1,81 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { PRComment } from '../../../../src/shared/types'
import {
createBotAuthorOverrideSet,
filterPRCommentsByAudience,
getPRCommentAudienceCounts,
isBotPRComment
} from './pr-comment-audience'
import { groupPRComments, isResolvedPRCommentGroup } from './pr-comment-groups'
import { formatPrCommentRelativeTime } from './pr-comment-time'
function comment(overrides: Partial<PRComment> & { id: number }): PRComment {
return {
author: 'octocat',
authorAvatarUrl: '',
body: '',
createdAt: '',
url: '',
...overrides
}
}
describe('pr comment audience', () => {
it('classifies app bots, [bot] suffix, and known automation logins', () => {
expect(isBotPRComment(comment({ id: 1, author: 'alice' }))).toBe(false)
expect(isBotPRComment(comment({ id: 2, author: 'alice', isBot: true }))).toBe(true)
expect(isBotPRComment(comment({ id: 3, author: 'renovate[bot]' }))).toBe(true)
expect(isBotPRComment(comment({ id: 4, author: 'coderabbitai' }))).toBe(true)
})
it('counts and filters by audience', () => {
const comments = [
comment({ id: 1, author: 'alice' }),
comment({ id: 2, author: 'dependabot[bot]' }),
comment({ id: 3, author: 'bob' })
]
expect(getPRCommentAudienceCounts(comments)).toEqual({ all: 3, human: 2, bot: 1 })
expect(filterPRCommentsByAudience(comments, 'bot').map((c) => c.id)).toEqual([2])
expect(filterPRCommentsByAudience(comments, 'human').map((c) => c.id)).toEqual([1, 3])
expect(filterPRCommentsByAudience(comments, 'all')).toHaveLength(3)
})
it('classifies desktop-marked bot authors via overrides (desktop parity)', () => {
const overrides = createBotAuthorOverrideSet([' GretelFlux '])
const comments = [comment({ id: 1, author: 'alice' }), comment({ id: 2, author: 'gretelflux' })]
expect(isBotPRComment(comments[1])).toBe(false)
expect(isBotPRComment(comments[1], overrides)).toBe(true)
expect(getPRCommentAudienceCounts(comments, overrides)).toEqual({ all: 2, human: 1, bot: 1 })
expect(filterPRCommentsByAudience(comments, 'bot', overrides).map((c) => c.id)).toEqual([2])
})
})
describe('pr comment groups', () => {
it('threads comments sharing a threadId as root + replies, preserving order', () => {
const comments = [
comment({ id: 1, author: 'a' }),
comment({ id: 2, author: 'b', threadId: 't1', isResolved: true }),
comment({ id: 3, author: 'c', threadId: 't1' })
]
const groups = groupPRComments(comments)
expect(groups).toHaveLength(2)
expect(groups[0]).toEqual({ kind: 'standalone', comment: comments[0] })
expect(groups[1].kind).toBe('thread')
if (groups[1].kind === 'thread') {
expect(groups[1].root.id).toBe(2)
expect(groups[1].replies.map((r) => r.id)).toEqual([3])
}
expect(isResolvedPRCommentGroup(groups[1])).toBe(true)
})
})
describe('pr comment relative time', () => {
const now = Date.parse('2026-06-16T12:00:00Z')
it('formats buckets and rejects bad input', () => {
expect(formatPrCommentRelativeTime('2026-06-16T11:59:30Z', now)).toBe('just now')
expect(formatPrCommentRelativeTime('2026-06-16T11:30:00Z', now)).toBe('30m ago')
expect(formatPrCommentRelativeTime('2026-06-16T09:00:00Z', now)).toBe('3h ago')
expect(formatPrCommentRelativeTime('2026-06-10T12:00:00Z', now)).toBe('6d ago')
expect(formatPrCommentRelativeTime('not-a-date', now)).toBe('')
})
})
@@ -1,30 +0,0 @@
// Relative timestamp for PR comments (ISO string in, "Xm/Xh/Xd/Xmo/Xy" out),
// mirroring the desktop formatRelativeTime so the timeline reads the same. Pure +
// unit-testable; nowMs is passed in (Date.now() is unavailable in some contexts).
export function formatPrCommentRelativeTime(iso: string, nowMs: number): string {
const ts = Date.parse(iso)
if (Number.isNaN(ts)) {
return ''
}
const delta = nowMs - ts
if (delta < 60_000) {
return 'just now'
}
const minutes = Math.floor(delta / 60_000)
if (minutes < 60) {
return `${minutes}m ago`
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
return `${hours}h ago`
}
const days = Math.floor(hours / 24)
if (days < 30) {
return `${days}d ago`
}
const months = Math.floor(days / 30)
if (months < 12) {
return `${months}mo ago`
}
return `${Math.floor(months / 12)}y ago`
}
@@ -298,11 +298,12 @@ describe('MobileNativeChatComposer', () => {
})
it('moves the caret to the insert point after an autocomplete pick, then releases control', async () => {
const onChangeText = vi.fn()
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: '/c',
onChangeText: vi.fn(),
onChangeText,
onSend: vi.fn().mockResolvedValue(true),
agent: 'claude'
})
@@ -325,6 +326,7 @@ describe('MobileNativeChatComposer', () => {
(node) => node.type === 'Pressable' && !node.props.accessibilityLabel
)[0] as { props: { onPress: () => void } }
await act(async () => firstSuggestion.props.onPress())
expect(onChangeText).toHaveBeenCalledWith('/clear ')
// `/clear ` is 7 chars — the caret jumps just past the inserted command + space.
expect(input().props.selection).toEqual({ start: 7, end: 7 })
// The next native selection event releases control so manual placement still works.
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
@@ -7,10 +7,13 @@ import {
openMobileNativeChatSendBudget,
clearMobileNativeChatInput,
sendMobileNativeChatMessage,
sendMobileNativeChatMessageWithOutcome
sendMobileNativeChatMessageWithOutcome,
typeMobileNativeChatCommandWithOutcome
} from './mobile-native-chat-send'
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
afterEach(() => vi.useRealTimers())
function clientWithResponse(response: unknown): RpcClient {
return {
sendRequest: vi.fn().mockResolvedValue(response)
@@ -290,6 +293,65 @@ describe('sendMobileNativeChatMessage', () => {
})
})
describe('typeMobileNativeChatCommandWithOutcome', () => {
it('writes the Codex picker command as keys instead of one pasted text write', async () => {
vi.useFakeTimers()
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
})
const result = typeMobileNativeChatCommandWithOutcome({
client,
terminal: 'term',
command: '/model'
})
await vi.runAllTimersAsync()
await expect(result).resolves.toBe('accepted')
expect(
vi.mocked(client.sendRequest).mock.calls.map((call) => {
const params = call[1] as { text: string; enter: boolean }
return { text: params.text, enter: params.enter }
})
).toEqual(
['\x15', '/', 'm', 'o', 'd', 'e', 'l', '\r'].map((text) => ({
text,
enter: false
}))
)
})
it('retires a parked launch draft only with the final typed Enter', async () => {
vi.useFakeTimers()
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
})
const result = typeMobileNativeChatCommandWithOutcome({
client,
terminal: 'term',
command: '/model',
resolvedLaunchDraft: { text: 'seed', createdAt: 7 }
})
await vi.runAllTimersAsync()
await result
const params = vi.mocked(client.sendRequest).mock.calls.map((call) => call[1]) as Array<{
text: string
resolvedLaunchDraft?: { text: string; createdAt: number }
}>
expect(params.slice(0, -1).every((entry) => entry.resolvedLaunchDraft === undefined)).toBe(true)
expect(params.at(-1)).toMatchObject({
text: '\r',
resolvedLaunchDraft: { text: 'seed', createdAt: 7 }
})
})
})
describe('clearMobileNativeChatInput', () => {
const accepted = {
id: 'request',
@@ -2,6 +2,7 @@ import type { RpcClient } from '../transport/rpc-client'
import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response'
import { typeAgentTuiCommand } from '../../../src/shared/agent-tui-command-typing'
type MobileTerminalClient = {
id: string
@@ -93,6 +94,35 @@ export async function sendMobileNativeChatMessage(
return (await sendMobileNativeChatMessageWithOutcome(args)) === 'accepted'
}
export async function typeMobileNativeChatCommandWithOutcome(args: {
client: RpcClient
terminal: string
command: string
resolvedLaunchDraft?: { text: string; createdAt: number }
mobileClient?: MobileTerminalClient
deadline?: number
}): Promise<MobileNativeChatSendOutcome> {
let writeIndex = 0
return typeAgentTuiCommand({
command: args.command,
write: (key) => {
const isSubmit = writeIndex === args.command.length + 1
writeIndex += 1
return sendMobileNativeChatMessageWithOutcome({
client: args.client,
terminal: args.terminal,
text: key,
enter: false,
...(isSubmit && args.resolvedLaunchDraft
? { resolvedLaunchDraft: args.resolvedLaunchDraft }
: {}),
...(args.mobileClient ? { mobileClient: args.mobileClient } : {}),
...(args.deadline === undefined ? {} : { deadline: args.deadline })
})
}
})
}
/**
* Clear the agent's input line as its OWN write, before any body.
*
@@ -7,8 +7,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const sendWithOutcome = vi.fn()
const clearInputWrite = vi.fn()
const typeCommandWithOutcome = vi.fn()
vi.mock('./mobile-native-chat-send', () => ({
sendMobileNativeChatMessageWithOutcome: (...args: unknown[]) => sendWithOutcome(...args),
typeMobileNativeChatCommandWithOutcome: (...args: unknown[]) => typeCommandWithOutcome(...args),
clearMobileNativeChatInput: (...args: unknown[]) => clearInputWrite(...args),
openMobileNativeChatSendBudget: () => Date.now() + 15_000,
MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS: 15_000,
@@ -84,6 +86,8 @@ describe('useMobileNativeChatMessageSend', () => {
sendWithOutcome.mockResolvedValue('accepted')
clearInputWrite.mockReset()
clearInputWrite.mockResolvedValue(true)
typeCommandWithOutcome.mockReset()
typeCommandWithOutcome.mockResolvedValue('accepted')
acceptSend.mockReset()
holdUnconfirmedSend.mockReset()
onCommandSend.mockReset()
@@ -215,24 +219,46 @@ describe('useMobileNativeChatMessageSend', () => {
expect(onCommandSend).toHaveBeenCalledWith('/clear')
})
it('never creates an optimistic echo for an unknown slash token', async () => {
// `/model` is not in Claude's autocomplete catalog, but the session-option
// recorder still recognizes it without claiming a generic command ran.
mount(() => null)
await act(async () => {
await api!.send('/model sonnet')
})
expect(acceptSend).not.toHaveBeenCalled()
expect(onCommandSend).toHaveBeenCalledWith('/model sonnet')
})
it.each(['claude', 'openclaude'] as const)(
'keeps %s composer slash sends pasted',
async (agent) => {
// `/model` is not in Claude's autocomplete catalog, but the session-option
// recorder still recognizes it without claiming a generic command ran.
mount(() => null, agent)
await act(async () => {
await api!.send('/model sonnet')
})
expect(acceptSend).not.toHaveBeenCalled()
expect(onCommandSend).toHaveBeenCalledWith('/model sonnet')
expect(sendWithOutcome).toHaveBeenCalledOnce()
expect(typeCommandWithOutcome).not.toHaveBeenCalled()
}
)
it('classifies per agent: /model is a catalog command for Codex', async () => {
it('types Codex composer slash sends instead of pasting them', async () => {
mount(() => null, 'codex')
await act(async () => {
await api!.send('/model')
})
expect(acceptSend).not.toHaveBeenCalled()
expect(onCommandSend).toHaveBeenCalledWith('/model')
expect(typeCommandWithOutcome).toHaveBeenCalledWith(
expect.objectContaining({ command: '/model', terminal: 'term' })
)
expect(sendWithOutcome).not.toHaveBeenCalled()
})
it('keeps Codex skill sends pasted', async () => {
mount(() => null, 'codex')
await act(async () => {
await api!.send('$ref-oss')
})
expect(acceptSend).not.toHaveBeenCalled()
expect(onCommandSend).toHaveBeenCalledWith('$ref-oss')
expect(sendWithOutcome).toHaveBeenCalledWith(
expect.objectContaining({ text: '$ref-oss', terminal: 'term' })
)
expect(typeCommandWithOutcome).not.toHaveBeenCalled()
})
it('holds only chat sends for transcript confirmation on a lost ack', async () => {
@@ -248,16 +274,44 @@ describe('useMobileNativeChatMessageSend', () => {
expect(holdUnconfirmedSend).toHaveBeenCalledTimes(1)
})
it('dispatchCommand surfaces the outcome without echo or composer sync', async () => {
mount(() => ({ text: DRAFT, createdAt: 1 }))
it.each(['claude', 'openclaude'] as const)(
'keeps %s session-option commands pasted',
async (agent) => {
mount(() => ({ text: DRAFT, createdAt: 1 }), agent)
let outcome: string | undefined
await act(async () => {
outcome = await api!.dispatchCommand('/model sonnet', { delivery: 'type' })
})
expect(outcome).toBe('accepted')
expect(acceptSend).not.toHaveBeenCalled()
expect(onCommandSend).not.toHaveBeenCalled()
expect(sentArgs().resolvedLaunchDraft).toBeUndefined()
}
)
it('routes typed picker commands around the pasted composer-text send', async () => {
mount(() => null, 'codex')
let outcome: string | undefined
await act(async () => {
outcome = await api!.dispatchCommand('/model sonnet')
outcome = await api!.dispatchCommand('/model', { delivery: 'type' })
})
expect(outcome).toBe('accepted')
expect(acceptSend).not.toHaveBeenCalled()
expect(onCommandSend).not.toHaveBeenCalled()
expect(sentArgs().resolvedLaunchDraft).toBeUndefined()
expect(typeCommandWithOutcome).toHaveBeenCalledWith(
expect.objectContaining({ command: '/model', terminal: 'term' })
)
expect(sendWithOutcome).not.toHaveBeenCalled()
})
it('types Codex session-option commands without caller delivery metadata', async () => {
mount(() => null, 'codex')
await act(async () => {
await api!.dispatchCommand('/model')
})
expect(typeCommandWithOutcome).toHaveBeenCalledWith(
expect.objectContaining({ command: '/model', terminal: 'term' })
)
expect(sendWithOutcome).not.toHaveBeenCalled()
})
it('binds classification to the agent that started the send', async () => {
@@ -4,8 +4,11 @@ import {
clearMobileNativeChatInput,
openMobileNativeChatSendBudget,
sendMobileNativeChatMessageWithOutcome,
typeMobileNativeChatCommandWithOutcome,
type MobileNativeChatSendOutcome
} from './mobile-native-chat-send'
import type { CatalogCommandDelivery } from '../../../src/shared/agent-session-option-catalog'
import { isSlashCommandDraft } from '../../../src/shared/native-chat-slash-commands'
import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input'
import { classifyMobileNativeChatSend } from './mobile-native-chat-send-classification'
import {
@@ -32,7 +35,10 @@ export type MobileNativeChatMessageSend = {
answerQuestion: (text: string) => Promise<boolean>
/** Session-option command dispatch (e.g. `/model sonnet`) — never touches the
* composer draft; callers need the outcome to track dispatched state. */
dispatchCommand: (text: string) => Promise<MobileNativeChatSendOutcome>
dispatchCommand: (
text: string,
options?: { delivery?: CatalogCommandDelivery }
) => Promise<MobileNativeChatSendOutcome>
}
/** The native-chat send seam: one write path shared by composer sends, image
@@ -150,40 +156,42 @@ export function useMobileNativeChatMessageSend(args: {
return 'rejected'
}
}
const outcome = await sendMobileNativeChatMessageWithOutcome({
client,
terminal: handle,
text,
// Why: pre-clear only when nothing was deliberately pasted first. The heal
// above fires only for terminals a mobile image paste marked, so a desktop
// launch-draft prefill parked on the input line would otherwise glue onto
// this message. An image send already led its own paste with Ctrl+U, and a
// second one here would wipe the image it just pasted (desktop's image path
// likewise clears once, before the paste, and never again).
//
// Also skipped once the dedicated clear above ran: the line is already
// empty, and a Ctrl+U written immediately before body text in the SAME
// write reaches the agent as a literal control character rather than a
// keypress (observed live as a stray \x15 heading the received message).
clearInputFirst: !images?.length && !seededLaunchDraft,
...(syncComposer && typeof seededLaunchDraft?.createdAt === 'number'
? {
resolvedLaunchDraft: {
text: seededLaunchDraft.text,
createdAt: seededLaunchDraft.createdAt
}
}
: {}),
deadline,
...(deviceTokenRef.current
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
: {})
})
const classification = classifyMobileNativeChatSend(agent, text)
const mobileClient = deviceTokenRef.current
? { id: deviceTokenRef.current, type: 'mobile' as const }
: undefined
const resolvedLaunchDraft =
syncComposer && typeof seededLaunchDraft?.createdAt === 'number'
? { text: seededLaunchDraft.text, createdAt: seededLaunchDraft.createdAt }
: undefined
const outcome =
agent === 'codex' &&
classification !== 'chat' &&
isSlashCommandDraft(text) &&
!images?.length
? await typeMobileNativeChatCommandWithOutcome({
client,
terminal: handle,
command: text,
...(resolvedLaunchDraft ? { resolvedLaunchDraft } : {}),
...(mobileClient ? { mobileClient } : {}),
deadline
})
: await sendMobileNativeChatMessageWithOutcome({
client,
terminal: handle,
text,
// Why: an image send already cleared before its paste; a second
// clear here would wipe the image before submission.
clearInputFirst: !images?.length && !seededLaunchDraft,
...(resolvedLaunchDraft ? { resolvedLaunchDraft } : {}),
deadline,
...(mobileClient ? { mobileClient } : {})
})
// Why (desktop parity): a slash/skill send dispatches into the agent's own
// TUI, not the conversation — the transcript never echoes it as a user
// turn, so an optimistic bubble would never reconcile and the
// unconfirmed hold could never observe a landing.
const classification = classifyMobileNativeChatSend(agent, text)
if (outcome === 'unknown') {
if (classification === 'chat') {
// Why: an ack-lost send usually WAS delivered (issue seen on cellular
@@ -269,12 +277,41 @@ export function useMobileNativeChatMessageSend(args: {
// spaces a send's body and its Enter ~500ms apart — so without this lock an
// apply lands between them and is submitted as part of the user's prompt.
const dispatchCommand = useCallback(
async (text: string): Promise<MobileNativeChatSendOutcome> => {
async (
text: string,
_options?: { delivery?: CatalogCommandDelivery }
): Promise<MobileNativeChatSendOutcome> => {
const terminal = handleRef.current
if (terminal && !acquireMobileNativeChatTerminalWrite(terminal)) {
return 'rejected'
}
try {
if (agentRef.current === 'codex') {
if (!client || !terminal || !enabled) {
return 'rejected'
}
const deadline = openMobileNativeChatSendBudget()
const mobileClient = deviceTokenRef.current
? { id: deviceTokenRef.current, type: 'mobile' as const }
: undefined
if (
!(await healMobileNativeChatStaleInput({
client,
terminal,
deviceToken: deviceTokenRef.current,
deadline
}))
) {
return 'rejected'
}
return typeMobileNativeChatCommandWithOutcome({
client,
terminal,
command: text,
...(mobileClient ? { mobileClient } : {}),
deadline
})
}
return await sendMessage(text, undefined, false, false)
} finally {
if (terminal) {
@@ -282,7 +319,7 @@ export function useMobileNativeChatMessageSend(args: {
}
}
},
[handleRef, sendMessage]
[client, deviceTokenRef, enabled, handleRef, sendMessage]
)
return { send, sendWithOutcome, answerQuestion, dispatchCommand }
@@ -14,7 +14,7 @@ describe('useMobileNativeChatSessionOptions', () => {
let renderer: ReactTestRenderer | null = null
let api: MobileNativeChatSessionOptionsController | null = null
let hookArgs: HookArgs
const dispatchCommand = vi.fn<(command: string) => Promise<MobileNativeChatSendOutcome>>()
const dispatchCommand = vi.fn<HookArgs['dispatchCommand']>()
const onAgentPicker = vi.fn()
function Probe(): null {
@@ -97,14 +97,23 @@ describe('useMobileNativeChatSessionOptions', () => {
expect(api!.snapshot[0]).toMatchObject({ valueSource: 'unknown' })
})
it('applies Codex model changes through the native command', async () => {
it('types the Codex picker command and switches to the terminal', async () => {
mount({ agent: 'codex' })
expect(api!.snapshot[0]?.action).toBeUndefined()
expect(api!.snapshot[0]?.action).toEqual({ type: 'agent-picker' })
await act(async () => {
await api!.setOption('model', 'gpt-5.5')
await api!.invokeAction('model')
})
expect(dispatchCommand).toHaveBeenCalledWith('/model gpt-5.5')
expect(onAgentPicker).not.toHaveBeenCalled()
expect(dispatchCommand).toHaveBeenCalledWith('/model', { delivery: 'type' })
expect(onAgentPicker).toHaveBeenCalledOnce()
})
it('types the Codex effort picker command', async () => {
mount({ agent: 'codex', reportedModel: 'gpt-5.5' })
await act(async () => {
await api!.invokeAction('effort')
})
expect(dispatchCommand).toHaveBeenCalledWith('/model', { delivery: 'type' })
expect(onAgentPicker).toHaveBeenCalledOnce()
})
it('seeds the current model from a hook-reported provider model', () => {
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import {
getAgentSessionOptionCatalog,
type AgentSessionOptionCatalog,
type CatalogCommandDelivery,
type CatalogModel
} from '../../../src/shared/agent-session-option-catalog'
import type {
@@ -98,7 +99,10 @@ export function useMobileNativeChatSessionOptions(args: {
scopeKey: string | null
/** Provider model from live agent status, when the hook reported one. */
reportedModel: string | null
dispatchCommand: (command: string) => Promise<MobileNativeChatSendOutcome>
dispatchCommand: (
command: string,
options?: { delivery?: CatalogCommandDelivery }
) => Promise<MobileNativeChatSendOutcome>
/** A model change that must happen in the agent's own TUI picker was
* dispatched — bring the terminal view forward. */
onAgentPicker?: () => void
@@ -300,7 +304,9 @@ export function useMobileNativeChatSessionOptions(args: {
?.options.find((option) => option.id === id)?.apply
const midSession = apply?.midSession
if (midSession?.kind === 'agent-picker') {
const outcome = await dispatchCommand(midSession.command)
const outcome = midSession.delivery
? await dispatchCommand(midSession.command, { delivery: midSession.delivery })
: await dispatchCommand(midSession.command)
if (outcome === 'rejected') {
return false
}
@@ -1,15 +0,0 @@
import { describe, expect, it } from 'vitest'
import { mapBaseRefResults } from './mobile-base-ref-search'
describe('mapBaseRefResults', () => {
it('extracts a clean string list from a well-formed payload', () => {
expect(mapBaseRefResults({ refs: ['main', 'dev'] })).toEqual(['main', 'dev'])
})
it('returns [] for malformed payloads and drops bad entries', () => {
expect(mapBaseRefResults(null)).toEqual([])
expect(mapBaseRefResults({})).toEqual([])
expect(mapBaseRefResults({ refs: 'x' })).toEqual([])
expect(mapBaseRefResults({ refs: ['ok', 2, '', null, 'two'] })).toEqual(['ok', 'two'])
})
})
@@ -1,31 +0,0 @@
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { mobileRepoSelectorFromWorktreeId } from './mobile-pr-create'
export function mapBaseRefResults(raw: unknown): string[] {
if (raw === null || typeof raw !== 'object') {
return []
}
const refs = (raw as { refs?: unknown }).refs
if (!Array.isArray(refs)) {
return []
}
return refs.filter((r): r is string => typeof r === 'string' && r.length > 0)
}
export async function searchBaseRefs(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
query: string,
limit = 20
): Promise<string[]> {
const response = await client.sendRequest('repo.searchRefs', {
repo: mobileRepoSelectorFromWorktreeId(worktreeId),
query,
limit
})
if (!response.ok) {
return []
}
return mapBaseRefResults((response as RpcSuccess).result)
}
@@ -1,268 +0,0 @@
import type { MobileGitUpstreamStatus } from './mobile-git-status'
export type MobileSourceControlPrimaryActionKind =
| 'commit'
| 'stage'
| 'push'
| 'pull'
| 'sync'
| 'publish'
export type MobileSourceControlRemoteOpKind =
| 'push'
| 'force_push'
| 'pull'
| 'sync'
| 'fetch'
| 'fast_forward'
| 'publish'
| 'rebase'
export type MobileSourceControlPrimaryActionTitleIntent =
| 'commit_in_progress'
| 'force_push_in_progress'
| 'action_in_progress'
| 'remote_operation_in_progress'
| 'remote_operation_blocks_commit'
| 'resolve_conflicts_before_commit'
| 'commit_staged_changes'
| 'enter_commit_message'
| 'stage_all_changes'
| 'stage_file_to_commit'
| 'checkout_branch_before_publish'
| 'publish_branch'
| 'force_push_with_lease'
| 'sync_counts'
| 'pull_count'
| 'push_count'
| 'nothing_to_commit_up_to_date'
export type MobileSourceControlPrimaryActionDecision = {
kind: MobileSourceControlPrimaryActionKind
disabled: boolean
labelIntent: MobileSourceControlPrimaryActionKind | 'force_push'
titleIntent: MobileSourceControlPrimaryActionTitleIntent
count?: number
ahead?: number
behind?: number
upstreamName?: string
requiresForceWithLease?: boolean
}
export type MobileSourceControlPrimaryActionDecisionInputs = {
stagedCount: number
hasUnstagedChanges: boolean
hasStageableChanges: boolean
hasPartiallyStagedChanges: boolean
hasMessage: boolean
hasUnresolvedConflicts: boolean
isCommitting: boolean
isRemoteOperationActive: boolean
upstreamStatus: MobileGitUpstreamStatus | undefined
inFlightRemoteOpKind?: MobileSourceControlRemoteOpKind | null
branchCommitsAhead?: number
hasCurrentBranch?: boolean
}
// Why: Metro cannot load runtime modules from the desktop/root `src/shared`
// tree. Keep this mobile mirror narrow and parity-tested against the shared
// commit-area decision core so the semantic ladder cannot drift silently.
export function resolveMobileSourceControlCommitAreaPrimaryActionDecision(
inputs: MobileSourceControlPrimaryActionDecisionInputs
): MobileSourceControlPrimaryActionDecision {
const {
stagedCount,
hasUnstagedChanges,
hasStageableChanges,
hasMessage,
hasUnresolvedConflicts,
isCommitting,
isRemoteOperationActive,
upstreamStatus,
branchCommitsAhead,
hasCurrentBranch = true
} = inputs
if (isCommitting) {
return {
kind: 'commit',
labelIntent: 'commit',
titleIntent: 'commit_in_progress',
disabled: true
}
}
if (isRemoteOperationActive) {
return resolveMobilePrimaryActionDuringRemoteOp(inputs)
}
if (hasUnresolvedConflicts) {
return {
kind: 'commit',
labelIntent: 'commit',
titleIntent: 'resolve_conflicts_before_commit',
disabled: true
}
}
const hasStaged = stagedCount > 0
if (hasStaged && hasMessage) {
return {
kind: 'commit',
labelIntent: 'commit',
titleIntent: 'commit_staged_changes',
disabled: false
}
}
if (hasStaged && !hasMessage) {
return {
kind: 'commit',
labelIntent: 'commit',
titleIntent: 'enter_commit_message',
disabled: true
}
}
if (!hasStaged && hasStageableChanges) {
return {
kind: 'stage',
labelIntent: 'stage',
titleIntent: 'stage_all_changes',
disabled: false
}
}
if (!upstreamStatus) {
return {
kind: 'commit',
labelIntent: 'commit',
titleIntent: 'stage_file_to_commit',
disabled: true
}
}
if (!upstreamStatus.hasUpstream) {
if (!hasCurrentBranch) {
return {
kind: 'commit',
labelIntent: 'commit',
titleIntent: 'checkout_branch_before_publish',
disabled: true
}
}
return {
kind: 'publish',
labelIntent: 'publish',
titleIntent: 'publish_branch',
disabled: false
}
}
if (upstreamStatus.ahead > 0 && upstreamStatus.behind > 0) {
if (shouldForcePushWithLeaseForMobileUpstream(upstreamStatus)) {
return {
kind: 'push',
labelIntent: 'force_push',
titleIntent: 'force_push_with_lease',
disabled: false,
count: branchCommitsAhead,
upstreamName: upstreamStatus.upstreamName,
requiresForceWithLease: true
}
}
return {
kind: 'sync',
labelIntent: 'sync',
titleIntent: 'sync_counts',
disabled: false,
ahead: upstreamStatus.ahead,
behind: upstreamStatus.behind
}
}
if (upstreamStatus.behind > 0) {
return {
kind: 'pull',
labelIntent: 'pull',
titleIntent: 'pull_count',
disabled: false,
count: upstreamStatus.behind
}
}
if (upstreamStatus.ahead > 0) {
return {
kind: 'push',
labelIntent: 'push',
titleIntent: 'push_count',
disabled: false,
count: upstreamStatus.ahead
}
}
return {
kind: 'commit',
labelIntent: 'commit',
titleIntent: hasUnstagedChanges ? 'stage_file_to_commit' : 'nothing_to_commit_up_to_date',
disabled: true
}
}
function resolveMobilePrimaryActionDuringRemoteOp(
inputs: MobileSourceControlPrimaryActionDecisionInputs
): MobileSourceControlPrimaryActionDecision {
const { inFlightRemoteOpKind, hasUnresolvedConflicts } = inputs
const candidate = resolveMobileSourceControlCommitAreaPrimaryActionDecision({
...inputs,
isRemoteOperationActive: false
})
const inFlightIsPrimaryKind =
inFlightRemoteOpKind === 'push' ||
inFlightRemoteOpKind === 'pull' ||
inFlightRemoteOpKind === 'sync' ||
inFlightRemoteOpKind === 'publish'
if (inFlightRemoteOpKind === 'force_push') {
return {
kind: 'push',
labelIntent: 'force_push',
titleIntent: 'force_push_in_progress',
disabled: true,
requiresForceWithLease: true
}
}
if (inFlightIsPrimaryKind && candidate.kind !== inFlightRemoteOpKind) {
return {
kind: inFlightRemoteOpKind,
labelIntent: inFlightRemoteOpKind,
titleIntent: 'action_in_progress',
disabled: true
}
}
const titleIntent = hasUnresolvedConflicts
? 'resolve_conflicts_before_commit'
: candidate.kind === 'commit'
? 'remote_operation_blocks_commit'
: 'remote_operation_in_progress'
return {
...candidate,
titleIntent,
disabled: true
}
}
function shouldForcePushWithLeaseForMobileUpstream(
status: MobileGitUpstreamStatus | undefined
): boolean {
return (
status?.hasUpstream === true &&
status.ahead > 0 &&
status.behind > 0 &&
status.behindCommitsArePatchEquivalent === true
)
}
@@ -1,9 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
import { resolveSourceControlCommitAreaPrimaryActionDecision } from '../../../src/shared/source-control-primary-action-decision'
import {
resolveMobileSourceControlCommitAreaPrimaryActionDecision,
type MobileSourceControlPrimaryActionDecisionInputs
} from './mobile-source-control-primary-action-decision'
import {
buildMobileSourceControlPrimaryAction,
type MobileSourceControlPrimaryActionArgs,
@@ -159,113 +154,3 @@ describe('buildMobileSourceControlPrimaryAction', () => {
expect(action.disabled).toBe(false)
})
})
function decisionInputs(
overrides: Partial<MobileSourceControlPrimaryActionDecisionInputs> = {}
): MobileSourceControlPrimaryActionDecisionInputs {
return {
stagedCount: 0,
hasUnstagedChanges: false,
hasStageableChanges: false,
hasPartiallyStagedChanges: false,
hasMessage: false,
hasUnresolvedConflicts: false,
isCommitting: false,
isRemoteOperationActive: false,
upstreamStatus: undefined,
...overrides
}
}
describe('mobile source-control primary action decision parity', () => {
it.each([
{
name: 'dirty tree stages first',
input: decisionInputs({
hasUnstagedChanges: true,
hasStageableChanges: true,
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 2 }
})
},
{
name: 'staged message commits',
input: decisionInputs({ stagedCount: 1, hasMessage: true })
},
{
name: 'staged without message blocks commit',
input: decisionInputs({ stagedCount: 1, hasMessage: false })
},
{
name: 'unresolved conflicts block commit',
input: decisionInputs({ stagedCount: 1, hasMessage: true, hasUnresolvedConflicts: true })
},
{
name: 'unpublished branch publishes',
input: decisionInputs({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
hasCurrentBranch: true
})
},
{
name: 'detached head blocks publish',
input: decisionInputs({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
hasCurrentBranch: false
})
},
{
name: 'tracked ahead pushes',
input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 } })
},
{
name: 'tracked behind pulls',
input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 0, behind: 3 } })
},
{
name: 'tracked diverged syncs',
input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 3 } })
},
{
name: 'patch-equivalent diverged force-pushes with lease',
input: decisionInputs({
branchCommitsAhead: 4,
upstreamStatus: {
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 10,
behind: 2,
behindCommitsArePatchEquivalent: true
}
})
},
{
name: 'in-flight pull mirrors pull',
input: decisionInputs({
isRemoteOperationActive: true,
inFlightRemoteOpKind: 'pull',
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }
})
},
{
name: 'in-flight force push mirrors force push',
input: decisionInputs({
isRemoteOperationActive: true,
inFlightRemoteOpKind: 'force_push',
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }
})
},
{
name: 'in-flight push blocks committable candidate',
input: decisionInputs({
isRemoteOperationActive: true,
inFlightRemoteOpKind: 'push',
stagedCount: 1,
hasMessage: true
})
}
])('matches the shared commit-area decision for $name', ({ input }) => {
expect(resolveMobileSourceControlCommitAreaPrimaryActionDecision(input)).toEqual(
resolveSourceControlCommitAreaPrimaryActionDecision(input)
)
})
})
@@ -1,14 +1,17 @@
import {
resolveMobileSourceControlCommitAreaPrimaryActionDecision,
type MobileSourceControlPrimaryActionDecision,
type MobileSourceControlPrimaryActionKind,
type MobileSourceControlRemoteOpKind
} from './mobile-source-control-primary-action-decision'
resolveSourceControlCommitAreaPrimaryActionDecision,
type SourceControlCommitAreaPrimaryActionDecision,
type SourceControlRemoteOpKind
} from '../../../src/shared/source-control-primary-action-decision'
import type { MobileGitBranchCompareResult } from './mobile-branch-compare'
import type { MobileGitStatusResult } from './mobile-git-status'
type GitStep = { method: string; params?: Record<string, unknown> }
type MobileSourceControlPrimaryActionKind = SourceControlCommitAreaPrimaryActionDecision['kind']
type MobileSourceControlPrimaryActionDecision = SourceControlCommitAreaPrimaryActionDecision
type MobileSourceControlRemoteOpKind = SourceControlRemoteOpKind
export type MobileSourceControlPrimaryAction = {
kind: MobileSourceControlPrimaryActionKind
label: string
@@ -44,7 +47,7 @@ export type MobileSourceControlPrimaryActionArgs = {
export function buildMobileSourceControlPrimaryAction(
args: MobileSourceControlPrimaryActionArgs
): MobileSourceControlPrimaryAction {
const decision = resolveMobileSourceControlCommitAreaPrimaryActionDecision({
const decision = resolveSourceControlCommitAreaPrimaryActionDecision({
stagedCount: args.stagedCount,
hasUnstagedChanges: args.unstagedCount > 0,
hasStageableChanges: args.stageablePaths.length > 0,
@@ -167,8 +170,16 @@ function getMobilePrimaryActionHint(decision: MobileSourceControlPrimaryActionDe
return 'Stage at least one file to commit.'
case 'checkout_branch_before_publish':
return 'Check out a branch before publishing commits.'
case 'checking_review_status':
return 'Checking review status.'
case 'review_already_merged':
return 'Nothing to commit. The review is already merged.'
case 'publish_branch':
return 'Publish this branch to origin.'
case 'push_linked_review':
return 'Push updates to the linked review branch.'
case 'linked_review_target_unavailable':
return 'The linked review branch is unavailable.'
case 'force_push_with_lease':
return 'Force push with lease to update the remote branch.'
case 'sync_counts':
@@ -1,84 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
canSubmitPrCompose,
getPrComposeDisabledReason,
isBaseHeadDistinct
} from './pr-compose-validation'
describe('isBaseHeadDistinct', () => {
it('is true when base and head differ', () => {
expect(isBaseHeadDistinct('main', 'feature')).toBe(true)
})
it('is false when they match after normalization (case-insensitive, prefix-stripped)', () => {
expect(isBaseHeadDistinct('main', 'main')).toBe(false)
expect(isBaseHeadDistinct('refs/heads/main', 'main')).toBe(false)
expect(isBaseHeadDistinct('origin/Main', 'main')).toBe(false)
expect(isBaseHeadDistinct('upstream/main', 'refs/heads/main')).toBe(false)
})
it('is false for an empty base', () => {
expect(isBaseHeadDistinct('', 'feature')).toBe(false)
})
})
describe('canSubmitPrCompose', () => {
it('requires a non-empty title and a distinct base', () => {
expect(canSubmitPrCompose('Title', 'main', 'feature')).toBe(true)
expect(canSubmitPrCompose(' ', 'main', 'feature')).toBe(false)
expect(canSubmitPrCompose('Title', 'main', 'main')).toBe(false)
})
})
describe('getPrComposeDisabledReason', () => {
it('returns null when the form can submit', () => {
expect(
getPrComposeDisabledReason({
title: 'Title',
base: 'main',
head: 'feature',
generating: false,
reviewLabel: 'pull request'
})
).toBeNull()
})
it('names the active blocker', () => {
expect(
getPrComposeDisabledReason({
title: 'Title',
base: 'main',
head: 'feature',
generating: true,
reviewLabel: 'pull request'
})
).toBe('Wait for generation to finish.')
expect(
getPrComposeDisabledReason({
title: '',
base: 'main',
head: 'feature',
generating: false,
reviewLabel: 'merge request'
})
).toBe('Enter a merge request title.')
expect(
getPrComposeDisabledReason({
title: 'Title',
base: '',
head: 'feature',
generating: false,
reviewLabel: 'pull request'
})
).toBe('Choose a base branch.')
expect(
getPrComposeDisabledReason({
title: 'Title',
base: 'main',
head: 'main',
generating: false,
reviewLabel: 'pull request'
})
).toBe('Base branch must differ from the head branch.')
})
})
@@ -1,55 +0,0 @@
// Ref normalizers ported from src/shared/hosted-review-refs.ts (value-imported, not
// referenced across the package boundary — Metro's resolver is rooted at mobile/ and
// can't bundle a runtime import from repo-root/src, unlike erased `import type`s).
// Keep in sync with the shared versions so mobile and desktop compare refs identically.
function normalizeHeadRef(ref: string): string {
return ref
.trim()
.replace(/^refs\/heads\//, '')
.replace(/^refs\/remotes\/[^/]+\//, '')
}
function normalizeBaseRef(ref: string): string {
return normalizeHeadRef(ref).replace(/^(origin|upstream)\//, '')
}
// Submit-gating for the create-PR composer, matching desktop CreatePullRequestDialog:
// the base ref must be non-empty and must differ from the head branch after ref
// normalization (strip refs/heads, remote prefixes, origin/upstream), case-insensitive.
export function isBaseHeadDistinct(base: string, head: string): boolean {
const b = normalizeBaseRef(base).toLowerCase()
const h = normalizeHeadRef(head).toLowerCase()
return b.length > 0 && b !== h
}
export function canSubmitPrCompose(title: string, base: string, head: string): boolean {
return title.trim().length > 0 && isBaseHeadDistinct(base, head)
}
export function getPrComposeDisabledReason({
title,
base,
head,
generating,
reviewLabel
}: {
title: string
base: string
head: string
generating: boolean
reviewLabel: string
}): string | null {
if (generating) {
return 'Wait for generation to finish.'
}
if (title.trim().length === 0) {
return `Enter a ${reviewLabel} title.`
}
if (base.trim().length === 0) {
return 'Choose a base branch.'
}
if (!isBaseHeadDistinct(base, head)) {
return 'Base branch must differ from the head branch.'
}
return null
}
@@ -1,261 +0,0 @@
export type GitHubProjectSortDirection = 'ASC' | 'DESC'
export type GitHubProjectFieldValue =
| { kind: 'text'; text: string }
| { kind: 'number'; number: number }
| { kind: 'date'; date: string }
| { kind: 'single-select'; optionId: string; name: string }
| { kind: 'iteration'; iterationId: string; title: string }
| { kind: 'labels'; labels: Array<{ name: string }> }
| { kind: 'users'; users: Array<{ login: string }> }
export type GitHubProjectField = {
id: string
name: string
kind: string
options?: Array<{ id: string }>
iterations?: Array<{
id: string
title?: string
startDate: string
duration: number
completed: boolean
}>
}
export type GitHubProjectRow = {
position?: number | null
fieldValuesByFieldId: Record<string, GitHubProjectFieldValue>
}
export type GitHubProjectSort = {
direction: GitHubProjectSortDirection
field: GitHubProjectField
}
export type GitHubProjectTable = {
selectedView: {
groupByFields: GitHubProjectField[]
sortByFields: GitHubProjectSort[]
}
rows: GitHubProjectRow[]
}
export type ProjectGroup = {
key: string
label: string
iteration: {
startDate: string
duration: number
completed: boolean
} | null
rows: GitHubProjectRow[]
}
const EMPTY_GROUP_KEY = '__empty__'
const UNKNOWN_INDEX_SENTINEL = Number.MAX_SAFE_INTEGER
function labelForEmpty(field: GitHubProjectField): string {
return `No ${field.name}`
}
function deriveStringValue(value: GitHubProjectFieldValue): string {
switch (value.kind) {
case 'text':
return value.text
case 'number':
return String(value.number)
case 'date':
return value.date
case 'single-select':
return value.name
case 'iteration':
return value.title
case 'labels':
return value.labels.map((label) => label.name).join(', ')
case 'users':
return value.users.map((user) => user.login).join(', ')
}
}
function getFieldValueForGrouping(
row: GitHubProjectRow,
field: GitHubProjectField
): { key: string; label: string; orderHint: number; iteration: ProjectGroup['iteration'] } {
const value = row.fieldValuesByFieldId[field.id]
if (!value) {
return {
key: EMPTY_GROUP_KEY,
label: labelForEmpty(field),
orderHint: UNKNOWN_INDEX_SENTINEL,
iteration: null
}
}
if (field.kind === 'iteration' && value.kind === 'iteration') {
const iterations = field.iterations ?? []
const idx = iterations.findIndex((it) => it.id === value.iterationId)
const meta = iterations.find((it) => it.id === value.iterationId)
return {
key: value.iterationId,
label: value.title || meta?.title || 'Iteration',
orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx,
iteration: meta
? { startDate: meta.startDate, duration: meta.duration, completed: meta.completed }
: null
}
}
if (field.kind === 'single-select' && value.kind === 'single-select') {
const idx = (field.options ?? []).findIndex((option) => option.id === value.optionId)
return {
key: value.optionId,
label: value.name,
orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx,
iteration: null
}
}
const label = deriveStringValue(value)
return { key: `raw:${label}`, label, orderHint: 0, iteration: null }
}
export function groupRows(
table: GitHubProjectTable,
rowsInOrder: GitHubProjectRow[]
): ProjectGroup[] {
const groupField = table.selectedView.groupByFields[0]
if (!groupField) {
return [{ key: 'all', label: '', iteration: null, rows: rowsInOrder }]
}
const buckets = new Map<
string,
{
label: string
orderHint: number
iteration: ProjectGroup['iteration']
rows: GitHubProjectRow[]
}
>()
for (const row of rowsInOrder) {
const { key, label, orderHint, iteration } = getFieldValueForGrouping(row, groupField)
let bucket = buckets.get(key)
if (!bucket) {
bucket = { label, orderHint, iteration, rows: [] }
buckets.set(key, bucket)
}
bucket.rows.push(row)
}
const entries = Array.from(buckets.entries())
entries.sort((a, b) => {
if (a[0] === EMPTY_GROUP_KEY) {
return 1
}
if (b[0] === EMPTY_GROUP_KEY) {
return -1
}
if (groupField.kind === 'iteration' || groupField.kind === 'single-select') {
return a[1].orderHint - b[1].orderHint
}
return a[1].label.localeCompare(b[1].label)
})
return entries.map(([key, value]) => ({
key,
label: value.label,
iteration: value.iteration,
rows: value.rows
}))
}
function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProjectSort): number {
const field = sort.field
const aValue = a.fieldValuesByFieldId[field.id]
const bValue = b.fieldValuesByFieldId[field.id]
if (!aValue && !bValue) {
return 0
}
if (!aValue) {
return 1
}
if (!bValue) {
return -1
}
let cmp = 0
if (
field.kind === 'single-select' &&
aValue.kind === 'single-select' &&
bValue.kind === 'single-select'
) {
const options = field.options ?? []
const aIdx = options.findIndex((option) => option.id === aValue.optionId)
const bIdx = options.findIndex((option) => option.id === bValue.optionId)
cmp =
(aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx)
} else if (
field.kind === 'iteration' &&
aValue.kind === 'iteration' &&
bValue.kind === 'iteration'
) {
const iterations = field.iterations ?? []
const aIdx = iterations.findIndex((iteration) => iteration.id === aValue.iterationId)
const bIdx = iterations.findIndex((iteration) => iteration.id === bValue.iterationId)
cmp =
(aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx)
} else if (aValue.kind === 'number' && bValue.kind === 'number') {
cmp = aValue.number - bValue.number
} else if (aValue.kind === 'date' && bValue.kind === 'date') {
cmp = aValue.date.localeCompare(bValue.date)
} else if (aValue.kind === 'text' && bValue.kind === 'text') {
cmp = aValue.text.localeCompare(bValue.text)
} else if (aValue.kind === 'users' && bValue.kind === 'users') {
const aLogin = aValue.users[0]?.login ?? ''
const bLogin = bValue.users[0]?.login ?? ''
if (!aLogin && !bLogin) {
cmp = 0
} else if (!aLogin) {
cmp = 1
} else if (!bLogin) {
cmp = -1
} else {
cmp = aLogin.localeCompare(bLogin)
}
} else if (aValue.kind === 'labels' && bValue.kind === 'labels') {
const aName = aValue.labels[0]?.name ?? ''
const bName = bValue.labels[0]?.name ?? ''
if (!aName && !bName) {
cmp = 0
} else if (!aName) {
cmp = 1
} else if (!bName) {
cmp = -1
} else {
cmp = aName.localeCompare(bName)
}
} else {
return 0
}
return sort.direction === 'DESC' ? -cmp : cmp
}
export function sortRows(table: GitHubProjectTable, rows: GitHubProjectRow[]): GitHubProjectRow[] {
const sorts = table.selectedView.sortByFields
const out = [...rows]
out.sort((a, b) => {
for (const sort of sorts) {
const cmp = compareSort(a, b, sort)
if (cmp !== 0) {
return cmp
}
}
return (a.position ?? UNKNOWN_INDEX_SENTINEL) - (b.position ?? UNKNOWN_INDEX_SENTINEL)
})
return out
}
export function isIterationCurrent(iteration: { startDate: string; duration: number }): boolean {
const start = new Date(`${iteration.startDate}T00:00:00Z`).getTime()
if (Number.isNaN(start)) {
return false
}
const end = start + iteration.duration * 86_400_000
const now = Date.now()
return now >= start && now < end
}
@@ -0,0 +1,91 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
export const terminalSettingsScreenStyles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
paddingHorizontal: spacing.lg,
paddingTop: 0
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: spacing.sm,
marginBottom: spacing.lg
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.sm
},
heading: {
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary
},
scrollContent: {
paddingBottom: spacing.xl
},
groupHeading: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
letterSpacing: 0.5,
marginBottom: spacing.xs,
paddingHorizontal: spacing.xs
},
groupDescription: {
fontSize: typography.bodySize - 1,
color: colors.textSecondary,
lineHeight: 20,
paddingHorizontal: spacing.xs
},
section: {
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
overflow: 'hidden'
},
sectionTopGap: {
marginTop: spacing.sm
},
inputGroupGap: {
marginTop: spacing.xl
},
emptyText: {
fontSize: typography.bodySize,
color: colors.textSecondary,
padding: spacing.md
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
rowContent: {
flex: 1
},
rowLabel: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
rowSublabel: {
fontSize: typography.bodySize - 2,
color: colors.textSecondary,
marginTop: 2
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
}
})
@@ -1,41 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { startPairingConnectionAttempt } from './pairing-connection-attempt'
describe('pairing connection attempt cleanup', () => {
afterEach(() => {
vi.useRealTimers()
})
it('closes the temporary client when the overall pairing timeout fires', () => {
vi.useFakeTimers()
const closeClient = vi.fn()
const attempt = startPairingConnectionAttempt({ timeoutMs: 25_000, closeClient })
expect(attempt.timedOut).toBe(false)
vi.advanceTimersByTime(24_999)
expect(closeClient).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(attempt.timedOut).toBe(true)
expect(closeClient).toHaveBeenCalledTimes(1)
attempt.dispose()
expect(closeClient).toHaveBeenCalledTimes(1)
})
it('clears the timeout and closes the temporary client when disposed early', () => {
vi.useFakeTimers()
const closeClient = vi.fn()
const attempt = startPairingConnectionAttempt({ timeoutMs: 25_000, closeClient })
attempt.dispose()
expect(attempt.timedOut).toBe(false)
expect(closeClient).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(25_000)
expect(attempt.timedOut).toBe(false)
expect(closeClient).toHaveBeenCalledTimes(1)
})
})
@@ -1,50 +0,0 @@
export type PairingConnectionAttempt = {
readonly timedOut: boolean
dispose: () => void
}
export function startPairingConnectionAttempt({
timeoutMs,
closeClient
}: {
timeoutMs: number
closeClient: () => void
}): PairingConnectionAttempt {
let disposed = false
let clientClosed = false
let timedOut = false
let timer: ReturnType<typeof setTimeout> | null = null
function closeClientOnce() {
if (clientClosed) {
return
}
clientClosed = true
closeClient()
}
function dispose() {
if (disposed) {
return
}
disposed = true
if (timer) {
clearTimeout(timer)
timer = null
}
closeClientOnce()
}
timer = setTimeout(() => {
timer = null
timedOut = true
dispose()
}, timeoutMs)
return {
get timedOut() {
return timedOut
},
dispose
}
}
+5 -2
View File
@@ -408,8 +408,11 @@ def render_accessibility_tree(root, window_rect, root_path, compact_browser_tabs
item = record(node, len(records), path, window_rect)
child_items = list(children(node))
role_key = (item["controlType"] or "").lower()
summary_values = text_snippets(node, limit=8, max_depth=4)
generic_summary = " ".join(summary_values) if role_key in {"panel", "filler", "unknown", "section"} and not item["name"] and not item["value"] and len(summary_values) >= 2 and is_plain_text_subtree(node) else None
generic_summary = None
if role_key in {"panel", "filler", "unknown", "section"} and not item["name"] and not item["value"]:
summary_values = text_snippets(node, limit=8, max_depth=4)
if len(summary_values) >= 2 and is_plain_text_subtree(node):
generic_summary = " ".join(summary_values)
if should_elide(item, len(child_items), generic_summary):
for child_index, child in child_items:
walk(child, depth, path + [child_index])
@@ -0,0 +1,198 @@
import importlib.util
import sys
import types
import unittest
from pathlib import Path
class FakeStateType:
PROTECTED = 1
SELECTED = 2
class FakeText:
@staticmethod
def get_character_count(node):
return len(node.value)
@staticmethod
def get_text(node, start, end):
return node.value[start:end]
def load_runtime():
gi = types.ModuleType("gi")
repository = types.ModuleType("gi.repository")
gi.require_version = lambda *_: None
repository.Atspi = types.SimpleNamespace(StateType=FakeStateType, Text=FakeText)
repository.Gdk = types.SimpleNamespace()
repository.GdkPixbuf = types.SimpleNamespace()
gi.repository = repository
sys.modules["gi"] = gi
sys.modules["gi.repository"] = repository
path = Path(__file__).with_name("runtime.py")
spec = importlib.util.spec_from_file_location("orca_linux_runtime_test", path)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
runtime = load_runtime()
class FakeAccessible:
def __init__(self, role, name="", value="", children=(), counter=None):
self.role = role
self.name = name
self.value = value
self.children = list(children)
self.counter = counter or {"child_reads": 0}
for child in self.children:
child.use_counter(self.counter)
def use_counter(self, counter):
self.counter = counter
for child in self.children:
child.use_counter(counter)
def get_child_count(self):
self.counter["child_reads"] += 1
return len(self.children)
def get_child_at_index(self, index):
self.counter["child_reads"] += 1
return self.children[index]
def get_role_name(self):
return self.role
def get_name(self):
return self.name
def get_accessible_id(self):
return ""
def get_toolkit_name(self):
return "fake"
def get_component_iface(self):
return None
def get_state_set(self):
return None
def get_n_actions(self):
return 0
def is_text(self):
return bool(self.value)
def get_text_iface(self):
return self if self.value else None
def get_value_iface(self):
return None
class FailingChildrenAccessible(FakeAccessible):
def get_child_count(self):
raise RuntimeError("defunct node")
def render(root):
return runtime.render_accessibility_tree(root, None, [0])
class RuntimeRenderTest(unittest.TestCase):
def test_named_non_generic_node_skips_unused_summary_walk(self):
root = FakeAccessible("button", "Save", children=[FakeAccessible("text", "unused")])
records, lines, truncation = render(root)
self.assertEqual([record["name"] for record in records], ["Save"])
self.assertEqual(lines, ["0 button Save"])
self.assertEqual(root.counter["child_reads"], 2)
self.assertFalse(truncation["truncated"])
def test_named_generic_node_skips_unused_summary_walk(self):
root = FakeAccessible("section", "Details", children=[FakeAccessible("text", "body")])
records, lines, _ = render(root)
self.assertEqual([record["name"] for record in records], ["Details", "body"])
self.assertEqual(lines, ["0 section Details", "\t1 text body"])
self.assertEqual(root.counter["child_reads"], 3)
def test_unnamed_generic_node_keeps_plain_text_summary(self):
root = FakeAccessible(
"section",
children=[FakeAccessible("text", "Alpha"), FakeAccessible("text", "Beta")],
)
records, lines, _ = render(root)
self.assertEqual(len(records), 1)
self.assertEqual(lines, ["0 section, Text: Alpha Beta"])
self.assertEqual(root.counter["child_reads"], 13)
def test_row_keeps_its_specific_summary_walk(self):
root = FakeAccessible(
"row",
"Invoice",
children=[FakeAccessible("text", "Alpha"), FakeAccessible("text", "Beta")],
)
records, lines, _ = render(root)
self.assertEqual([record["name"] for record in records], ["Invoice", "Alpha", "Beta"])
self.assertEqual(lines, ["0 row Invoice, Text: Alpha Beta", "\t1 text Alpha", "\t2 text Beta"])
self.assertEqual(root.counter["child_reads"], 10)
def test_empty_generic_wrapper_keeps_elision_path_and_depth(self):
root = FakeAccessible("section", children=[FakeAccessible("button", "Continue")])
records, lines, _ = render(root)
self.assertEqual([record["runtimeId"] for record in records], [[0, 0]])
self.assertEqual(lines, ["0 button Continue"])
def test_child_failure_keeps_fail_soft_row_output(self):
root = FailingChildrenAccessible("row", "Invoice")
records, lines, truncation = render(root)
self.assertEqual([record["name"] for record in records], ["Invoice"])
self.assertEqual(lines, ["0 row Invoice"])
self.assertFalse(truncation["truncated"])
def test_node_limit_keeps_exact_prefix_and_truncation(self):
root = FakeAccessible(
"document",
"Results",
children=[FakeAccessible("text", f"Item {index}") for index in range(runtime.MAX_NODES)],
)
records, _, truncation = render(root)
self.assertEqual(len(records), runtime.MAX_NODES)
self.assertEqual(records[-1]["name"], f"Item {runtime.MAX_NODES - 2}")
self.assertTrue(truncation["truncated"])
self.assertFalse(truncation["maxDepthReached"])
def test_depth_limit_keeps_exact_prefix_and_flag(self):
root = FakeAccessible("document", "Depth 65")
for depth in range(runtime.MAX_DEPTH, -1, -1):
root = FakeAccessible("document", f"Depth {depth}", children=[root])
records, _, truncation = render(root)
self.assertEqual(len(records), runtime.MAX_DEPTH + 1)
self.assertEqual(records[-1]["name"], f"Depth {runtime.MAX_DEPTH}")
self.assertTrue(truncation["truncated"])
self.assertTrue(truncation["maxDepthReached"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,224 @@
$ErrorActionPreference = "Stop"
function Assert-TestEqual($Actual, $Expected, [string]$Label) {
if ($Actual -ne $Expected) {
throw "$Label expected '$Expected', received '$Actual'"
}
}
Add-Type -TypeDefinition @"
using System.Collections;
using System.Collections.Generic;
public sealed class OrcaRenderTestCounter {
public int FindAll;
}
public sealed class OrcaRenderTestBounds {
public bool IsEmpty = true;
public double X;
public double Y;
public double Width;
public double Height;
}
public sealed class OrcaRenderTestControlType {
public string ProgrammaticName = "";
}
public sealed class OrcaRenderTestCurrent {
public string AutomationId = "";
public OrcaRenderTestBounds BoundingRectangle = new OrcaRenderTestBounds();
public string ClassName = "fake";
public OrcaRenderTestControlType ControlType = new OrcaRenderTestControlType();
public bool IsPassword;
public string LocalizedControlType = "";
public string Name = "";
public long NativeWindowHandle;
}
public sealed class OrcaRenderTestPatternCurrent {
public bool IsSelected;
public string Value = "";
}
public sealed class OrcaRenderTestPattern {
public OrcaRenderTestPatternCurrent Current = new OrcaRenderTestPatternCurrent();
}
public sealed class OrcaRenderTestCollection : IEnumerable {
private readonly OrcaRenderTestElement[] values;
public OrcaRenderTestCollection(OrcaRenderTestElement[] values) {
this.values = values;
}
public int Count {
get { return values.Length; }
}
public OrcaRenderTestElement Item(int index) {
return values[index];
}
public IEnumerator GetEnumerator() {
return values.GetEnumerator();
}
}
public sealed class OrcaRenderTestElement {
public readonly List<OrcaRenderTestElement> Children =
new List<OrcaRenderTestElement>();
public OrcaRenderTestCounter Counter = new OrcaRenderTestCounter();
public readonly OrcaRenderTestCurrent Current = new OrcaRenderTestCurrent();
public bool FailFindAll;
public int RuntimeIdValue;
public string ValueText = "";
public OrcaRenderTestCollection FindAll(object scope, object condition) {
Counter.FindAll++;
if (FailFindAll) {
throw new System.InvalidOperationException("defunct node");
}
return new OrcaRenderTestCollection(Children.ToArray());
}
public OrcaRenderTestPattern GetCurrentPattern(object pattern) {
OrcaRenderTestPattern result = new OrcaRenderTestPattern();
result.Current.Value = ValueText;
return result;
}
public int[] GetRuntimeId() {
return new int[] { RuntimeIdValue };
}
public object[] GetSupportedPatterns() {
return new object[0];
}
}
"@
function New-TestCounter {
New-Object -TypeName OrcaRenderTestCounter
}
function New-TestElement {
param(
[string]$Role,
[string]$Name = "",
[string]$Value = "",
[object[]]$Children = @(),
$Counter = $(New-TestCounter),
[switch]$FailFindAll,
[int]$RuntimeId = 1
)
$element = New-Object -TypeName OrcaRenderTestElement
$element.Counter = $Counter
$element.FailFindAll = [bool]$FailFindAll
$element.RuntimeIdValue = $RuntimeId
$element.ValueText = $Value
$element.Current.ControlType.ProgrammaticName = $Role
$element.Current.LocalizedControlType = $Role
$element.Current.Name = $Name
foreach ($child in @($Children)) {
[void]$element.Children.Add($child)
}
$element
}
$operationPath = Join-Path ([IO.Path]::GetTempPath()) ("orca-runtime-render-test-" + [guid]::NewGuid() + ".json")
try {
Set-Content -LiteralPath $operationPath -Encoding UTF8 -Value '{"tool":"handshake"}'
$runtimeOutput = . (Join-Path $PSScriptRoot "runtime.ps1") -OperationPath $operationPath
$handshake = $runtimeOutput | ConvertFrom-Json
Assert-TestEqual $handshake.ok $true "runtime handshake"
$counter = New-TestCounter
$leaf = New-TestElement -Role "text" -Name "unused" -Counter $counter -RuntimeId 2
$root = New-TestElement -Role "button" -Name "Save" -Children @($leaf) -Counter $counter
$tree = Render-OrcaTree $root $null
Assert-TestEqual $tree.elements.Count 1 "named control record count"
Assert-TestEqual ([string]$tree.lines[0]) "0 button Save" "named control line"
Assert-TestEqual $counter.findAll 1 "named control child enumeration"
$counter = New-TestCounter
$leaf = New-TestElement -Role "text" -Name "body" -Counter $counter -RuntimeId 2
$root = New-TestElement -Role "group" -Name "Details" -Children @($leaf) -Counter $counter
$tree = Render-OrcaTree $root $null
Assert-TestEqual (($tree.elements | ForEach-Object { $_.name }) -join "|") "Details|body" "named generic records"
Assert-TestEqual (@($tree.lines) -join "|") "0 group Details|`t1 text body" "named generic lines"
Assert-TestEqual $counter.findAll 2 "named generic child enumeration"
$counter = New-TestCounter
$alpha = New-TestElement -Role "text" -Name "Alpha" -Counter $counter -RuntimeId 2
$beta = New-TestElement -Role "text" -Name "Beta" -Counter $counter -RuntimeId 3
$root = New-TestElement -Role "group" -Children @($alpha, $beta) -Counter $counter
$tree = Render-OrcaTree $root $null
Assert-TestEqual $tree.elements.Count 1 "anonymous generic record count"
Assert-TestEqual ([string]$tree.lines[0]) "0 group, Text: Alpha Beta" "anonymous generic summary"
Assert-TestEqual $counter.findAll 7 "anonymous generic child enumeration"
$counter = New-TestCounter
$alpha = New-TestElement -Role "text" -Name "Alpha" -Counter $counter -RuntimeId 2
$beta = New-TestElement -Role "text" -Name "Beta" -Counter $counter -RuntimeId 3
$root = New-TestElement -Role "row" -Name "Invoice" -Children @($alpha, $beta) -Counter $counter
$tree = Render-OrcaTree $root $null
Assert-TestEqual (($tree.elements | ForEach-Object { $_.name }) -join "|") "Invoice|Alpha|Beta" "row records"
Assert-TestEqual ([string]$tree.lines[0]) "0 row Invoice, Text: Alpha Beta" "row summary"
Assert-TestEqual $counter.findAll 6 "row child enumeration"
$counter = New-TestCounter
$button = New-TestElement -Role "button" -Name "Continue" -Counter $counter -RuntimeId 2
$root = New-TestElement -Role "group" -Children @($button) -Counter $counter
$tree = Render-OrcaTree $root $null
Assert-TestEqual $tree.elements.Count 1 "elided wrapper record count"
Assert-TestEqual ([string]$tree.lines[0]) "0 button Continue" "elided wrapper line"
Assert-TestEqual $counter.findAll 4 "elided wrapper child enumeration"
$counter = New-TestCounter
$root = New-TestElement -Role "row" -Name "Invoice" -Counter $counter -FailFindAll
$tree = Render-OrcaTree $root $null
Assert-TestEqual $tree.elements.Count 1 "failed child read record count"
Assert-TestEqual ([string]$tree.lines[0]) "0 row Invoice" "failed child read line"
Assert-TestEqual $counter.findAll 2 "failed child read retries"
$originalMaxNodes = $MaxNodes
try {
$MaxNodes = 3
$counter = New-TestCounter
$children = @()
for ($index = 0; $index -lt 3; $index++) {
$children += New-TestElement -Role "text" -Name "Item $index" -Counter $counter -RuntimeId ($index + 2)
}
$root = New-TestElement -Role "document" -Name "Results" -Children $children -Counter $counter
$tree = Render-OrcaTree $root $null
Assert-TestEqual $tree.elements.Count 3 "node limit record count"
Assert-TestEqual ([string]$tree.elements[-1].name) "Item 1" "node limit prefix"
Assert-TestEqual $tree.truncation.truncated $true "node limit truncation"
Assert-TestEqual $tree.truncation.maxDepthReached $false "node limit depth flag"
} finally {
$MaxNodes = $originalMaxNodes
}
$originalMaxDepth = $MaxDepth
try {
$MaxDepth = 2
$counter = New-TestCounter
$root = New-TestElement -Role "document" -Name "Depth 3" -Counter $counter -RuntimeId 4
for ($depth = 2; $depth -ge 0; $depth--) {
$root = New-TestElement -Role "document" -Name "Depth $depth" -Children @($root) -Counter $counter -RuntimeId ($depth + 1)
}
$tree = Render-OrcaTree $root $null
Assert-TestEqual $tree.elements.Count 3 "depth limit record count"
Assert-TestEqual ([string]$tree.elements[-1].name) "Depth 2" "depth limit prefix"
Assert-TestEqual $tree.truncation.truncated $true "depth limit truncation"
Assert-TestEqual $tree.truncation.maxDepthReached $true "depth limit flag"
} finally {
$MaxDepth = $originalMaxDepth
}
Write-Output "windows-snapshot-render-tests-ok"
} finally {
Remove-Item -LiteralPath $operationPath -Force -ErrorAction SilentlyContinue
}
+5 -3
View File
@@ -561,10 +561,12 @@ function Render-OrcaTree($RootElement, $WindowFrame, [bool]$CompactBrowserTabs =
$title = if ([string]::IsNullOrWhiteSpace($record.name)) { $record.automationId } else { $record.name }
$role = if ([string]::IsNullOrWhiteSpace($record.localizedControlType)) { $record.controlType } else { $record.localizedControlType }
$roleKey = $role.ToLowerInvariant()
$snippets = @(Get-OrcaTextSnippets $Node 8 4)
$genericSummary = $null
if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value) -and $snippets.Count -ge 2 -and (Test-OrcaPlainTextSubtree $Node)) {
$genericSummary = ($snippets -join " ")
if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value)) {
$snippets = @(Get-OrcaTextSnippets $Node 8 4)
if ($snippets.Count -ge 2 -and (Test-OrcaPlainTextSubtree $Node)) {
$genericSummary = ($snippets -join " ")
}
}
if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value) -and $meaningfulActions.Count -eq 0 -and $null -eq $genericSummary -and $children.Count -le 1) {
for ($i = 0; $i -lt $children.Count; $i++) {
@@ -7,7 +7,9 @@ const mocks = vi.hoisted(() => ({
removeClaude: vi.fn(),
removeCodex: vi.fn(),
statusClaude: vi.fn(),
statusCodex: vi.fn()
statusCodex: vi.fn(),
refreshClaude: vi.fn(),
refreshCodex: vi.fn()
}))
vi.mock('./local-agent-cli-presence', () => ({
@@ -26,6 +28,10 @@ vi.mock('./managed-agent-hook-registry', () => ({
MANAGED_AGENT_HOOK_STATUS_READERS: [
['claude', mocks.statusClaude],
['codex', mocks.statusCodex]
],
MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: [
['claude', mocks.refreshClaude],
['codex', mocks.refreshCodex]
]
}))
@@ -51,6 +57,8 @@ describe('managed agent hook controls', () => {
mocks.installCodex.mockReturnValue(status('codex', 'installed'))
mocks.removeClaude.mockReturnValue(status('claude', 'not_installed'))
mocks.removeCodex.mockReturnValue(status('codex', 'not_installed'))
mocks.refreshClaude.mockResolvedValue(undefined)
mocks.refreshCodex.mockResolvedValue(undefined)
})
it('installs only agents with positively detected CLIs', async () => {
@@ -73,6 +81,78 @@ describe('managed agent hook controls', () => {
])
})
it('refreshes existing scripts for agents whose CLI is no longer detected', async () => {
mocks.detect.mockResolvedValue({
claude: { state: 'missing' },
codex: { state: 'found' }
})
await installManagedAgentHooks({ agentCmdOverrides: {} })
// Why (#11549 aftermath): the skipped agent's user-wide config still invokes the
// script, so a stale (leaking) script must not be frozen by the presence gate.
expect(mocks.refreshClaude).toHaveBeenCalledTimes(1)
expect(mocks.refreshCodex).toHaveBeenCalledTimes(1)
expect(mocks.installClaude).not.toHaveBeenCalled()
})
it('refreshes existing scripts even when CLI detection rejects', async () => {
mocks.detect.mockRejectedValue(new Error('detection unavailable'))
await installManagedAgentHooks({ agentCmdOverrides: {} })
expect(mocks.refreshClaude).toHaveBeenCalledTimes(1)
expect(mocks.refreshCodex).toHaveBeenCalledTimes(1)
})
it('awaits each script refresh before probing for CLIs', async () => {
let releaseRefresh: (() => void) | undefined
mocks.refreshClaude.mockImplementation(
() =>
new Promise<void>((resolve) => {
releaseRefresh = resolve
})
)
mocks.detect.mockResolvedValue({
claude: { state: 'missing' },
codex: { state: 'missing' }
})
const install = installManagedAgentHooks({ agentCmdOverrides: {} })
await Promise.resolve()
expect(mocks.detect).not.toHaveBeenCalled()
releaseRefresh?.()
await install
expect(mocks.detect).toHaveBeenCalledTimes(1)
})
it('keeps installing when a script refresh throws', async () => {
mocks.refreshClaude.mockRejectedValue(new Error('disk full'))
mocks.detect.mockResolvedValue({
claude: { state: 'found' },
codex: { state: 'found' }
})
const results = await installManagedAgentHooks({ agentCmdOverrides: {} })
expect(mocks.installClaude).toHaveBeenCalledTimes(1)
expect(mocks.installCodex).toHaveBeenCalledTimes(1)
expect(results).toEqual([
expect.objectContaining({ agent: 'claude', state: 'installed' }),
expect.objectContaining({ agent: 'codex', state: 'installed' })
])
})
it('only refreshes scripts for the selected agents', async () => {
mocks.detect.mockResolvedValue({ codex: { state: 'found' } })
await installManagedAgentHooks({ agentCmdOverrides: {} }, { agents: ['codex'] })
expect(mocks.refreshClaude).not.toHaveBeenCalled()
expect(mocks.refreshCodex).toHaveBeenCalledTimes(1)
})
it('fails closed when CLI detection rejects', async () => {
mocks.detect.mockRejectedValue(new Error('detection unavailable'))
@@ -9,6 +9,7 @@ import { detectLocalManagedAgentCliPresence } from './local-agent-cli-presence'
import {
MANAGED_AGENT_HOOK_INSTALLERS,
MANAGED_AGENT_HOOK_REMOVERS,
MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS,
MANAGED_AGENT_HOOK_STATUS_READERS,
type ManagedAgentHookInstaller
} from './managed-agent-hook-registry'
@@ -87,10 +88,29 @@ function runInstaller(
}
}
// Why (#11549 aftermath): a CLI that falls off PATH keeps its user-wide config invoking
// Orca's script, but the presence gate below then skips install() forever, freezing the
// script at whatever Orca generated last. Existing scripts are Orca-owned, so bring them
// current before any gating; creating new ones remains install()'s presence-gated job.
async function refreshExistingManagedScripts(options: InstallOptions): Promise<void> {
const allowed = options.agents ? new Set(options.agents) : null
for (const [agent, refresh] of MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS) {
if (allowed !== null && !allowed.has(agent)) {
continue
}
try {
await refresh()
} catch (error) {
console.error(`[agent-hooks] Failed to refresh ${agent} managed script:`, error)
}
}
}
export async function installManagedAgentHooks(
settings: ManagedHookSettings = null,
options: InstallOptions = {}
): Promise<AgentHookInstallStatus[]> {
await refreshExistingManagedScripts(options)
const installers = selectedInstallers(options)
const disabled = new Set(normalizeDisabledTuiAgents(settings?.disabledTuiAgents))
const enabledInstallers = installers.filter(([agent]) => !disabled.has(agent))
@@ -16,6 +16,7 @@ import { kimiHookService } from '../kimi/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
export type ManagedAgentHookInstaller = readonly [HookInstallAgent, () => AgentHookInstallStatus]
export type ManagedAgentHookScriptRefresher = readonly [HookInstallAgent, () => Promise<void>]
export type ManagedAgentHookRemover = readonly [HookInstallAgent, () => AgentHookInstallStatus]
export type ManagedAgentHookStatusReader = readonly [HookInstallAgent, () => AgentHookInstallStatus]
@@ -36,6 +37,27 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[]
['kimi', () => kimiHookService.install()]
]
// Why: covers the shared launcher/statusline scripts under ~/.orca/agent-hooks — the files a
// user-wide agent config keeps invoking after the CLI falls off PATH. Amp and Hermes write
// provider-native plugin code into their own config dirs with their own install lifecycles,
// not shared launchers, so they are deliberately absent. Enforced by the coverage test in
// managed-hook-script-refresh.test.ts: a new installer that writes a launcher without adding
// a refresher here fails that test.
export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScriptRefresher[] = [
['claude', () => claudeHookService.refreshManagedScripts()],
['openclaude', () => openClaudeHookService.refreshManagedScripts()],
['codex', () => codexHookService.refreshManagedScripts()],
['gemini', () => geminiHookService.refreshManagedScripts()],
['antigravity', () => antigravityHookService.refreshManagedScripts()],
['cursor', () => cursorHookService.refreshManagedScripts()],
['droid', () => droidHookService.refreshManagedScripts()],
['command-code', () => commandCodeHookService.refreshManagedScripts()],
['grok', () => grokHookService.refreshManagedScripts()],
['copilot', () => copilotHookService.refreshManagedScripts()],
['devin', () => devinHookService.refreshManagedScripts()],
['kimi', () => kimiHookService.refreshManagedScripts()]
]
export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [
['claude', () => claudeHookService.remove()],
['openclaude', () => openClaudeHookService.remove()],
@@ -0,0 +1,101 @@
import type * as NodeFsModule from 'node:fs'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import type * as NodeFsPromisesModule from 'node:fs/promises'
import { tmpdir } from 'node:os'
import type * as NodeOsModule from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const state = vi.hoisted(() => ({
home: '',
syncCalls: [] as { name: string; target: string }[],
blockAsyncReads: false
}))
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFsModule>()
const wrapped: Record<string, unknown> = { ...actual }
for (const [name, value] of Object.entries(actual)) {
if (!name.endsWith('Sync') || typeof value !== 'function') {
continue
}
const original = value as ((...args: unknown[]) => unknown) & Record<string, unknown>
const recorder = (...args: unknown[]): unknown => {
state.syncCalls.push({ name, target: typeof args[0] === 'string' ? args[0] : '' })
return original(...args)
}
Object.assign(recorder, original)
wrapped[name] = recorder
}
return { ...wrapped, default: wrapped }
})
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFsPromisesModule>()
return {
...actual,
default: actual,
readFile: (...args: Parameters<typeof actual.readFile>) =>
state.blockAsyncReads && String(args[0]).startsWith(state.home)
? new Promise<never>(() => {})
: actual.readFile(...args)
}
})
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof NodeOsModule>()
return { ...actual, default: actual, homedir: () => state.home }
})
vi.mock('electron', () => ({ app: { getPath: () => '/tmp/orca-user-data' } }))
import { MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS } from './managed-agent-hook-registry'
function syncCallsUnderHome(): string[] {
return state.syncCalls
.filter((call) => call.target.startsWith(state.home))
.map((call) => `${call.name}(${call.target})`)
}
describe('managed hook script refresh stays off the main thread', () => {
beforeEach(async () => {
state.home = await mkdtemp(join(tmpdir(), 'orca-hook-refresh-main-thread-'))
state.syncCalls = []
state.blockAsyncReads = false
})
afterEach(async () => {
state.blockAsyncReads = false
await rm(state.home, { recursive: true, force: true })
})
it('uses no synchronous HOME filesystem calls for missing or stale scripts', async () => {
const hooksDir = join(state.home, '.orca', 'agent-hooks')
const claudeScript = join(
hooksDir,
process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh'
)
await mkdir(hooksDir, { recursive: true })
await writeFile(claudeScript, 'stale', 'utf-8')
state.syncCalls = []
for (const [, refresh] of MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS) {
await refresh()
}
expect(syncCallsUnderHome()).toEqual([])
})
it('keeps the event loop responsive while a HOME read is stalled', async () => {
state.blockAsyncReads = true
const refresh = MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS[0][1]()
let settled = false
void refresh.then(() => {
settled = true
})
await new Promise<void>((resolve) => setTimeout(resolve, 5))
expect(settled).toBe(false)
})
})
@@ -0,0 +1,206 @@
// Why (#11549 aftermath): a CLI that falls off PATH keeps its user-wide config invoking
// Orca's script while the presence gate skips install() forever. These tests pin the
// repair — existing scripts come current, missing ones are never created.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
existsSync,
chmodSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
utimesSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type * as osModule from 'node:os'
let isolatedUserDataDir = ''
let previousUserDataPath: string | undefined
beforeEach(() => {
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
isolatedUserDataDir = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-user-data-'))
process.env.ORCA_USER_DATA_PATH = isolatedUserDataDir
})
afterEach(() => {
if (previousUserDataPath === undefined) {
delete process.env.ORCA_USER_DATA_PATH
} else {
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
}
rmSync(isolatedUserDataDir, { recursive: true, force: true })
})
const { homedirMock } = vi.hoisted(() => ({
homedirMock: vi.fn<() => string>()
}))
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp/orca-user-data'
}
}))
vi.mock('os', async (importOriginal) => {
const actual = await importOriginal<typeof osModule>()
return {
...actual,
homedir: homedirMock.mockImplementation(actual.homedir)
}
})
import { refreshManagedScriptIfPresent } from './managed-hook-script-refresh'
import {
MANAGED_AGENT_HOOK_INSTALLERS,
MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS
} from './managed-agent-hook-registry'
import { ClaudeHookService } from '../claude/hook-service'
async function withPlatform<T>(platform: NodeJS.Platform, run: () => T | Promise<T>): Promise<T> {
const original = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
try {
return await run()
} finally {
if (original) {
Object.defineProperty(process, 'platform', original)
}
}
}
const STALE_WINDOWS_HOOK = [
'@echo off',
'setlocal',
'if "%ORCA_AGENT_HOOK_PORT%"=="" goto :orca_agent_hook_drain_stdin',
':orca_agent_hook_drain_stdin',
'"%SystemRoot%\\System32\\more.com" >nul 2>nul',
'exit /b 0',
''
].join('\r\n')
describe('refreshManagedScriptIfPresent', () => {
it('rewrites an existing script and refuses to create a missing one', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-unit-'))
try {
const present = join(dir, 'present.cmd')
writeFileSync(present, 'stale')
expect(await refreshManagedScriptIfPresent(present, 'fresh')).toBe(true)
expect(readFileSync(present, 'utf8')).toBe('fresh')
if (process.platform !== 'win32') {
chmodSync(present, 0o600)
}
const fixedTime = new Date(1_000)
utimesSync(present, fixedTime, fixedTime)
expect(await refreshManagedScriptIfPresent(present, 'fresh')).toBe(true)
expect(statSync(present).mtimeMs).toBe(fixedTime.getTime())
if (process.platform !== 'win32') {
expect(statSync(present).mode & 0o777).toBe(0o755)
}
const missing = join(dir, 'missing.cmd')
expect(await refreshManagedScriptIfPresent(missing, 'fresh')).toBe(false)
expect(existsSync(missing)).toBe(false)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})
describe('managed hook script refresh', () => {
it('brings a stale leaking script current without touching agent config', async () => {
const home = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-'))
homedirMock.mockReturnValue(home)
try {
// Why: the bug population has a script (from a past install) but no reachable
// CLI — and possibly no config dir Orca may create. Seed only the script.
const hooksDir = join(home, '.orca', 'agent-hooks')
mkdirSync(hooksDir, { recursive: true })
writeFileSync(join(hooksDir, 'claude-hook.cmd'), STALE_WINDOWS_HOOK)
await withPlatform('win32', () => new ClaudeHookService().refreshManagedScripts())
const refreshed = readFileSync(join(hooksDir, 'claude-hook.cmd'), 'utf8')
expect(refreshed).toContain('if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0')
expect(refreshed).not.toContain('if "%ORCA_AGENT_HOOK_PORT%"=="" goto')
// Why: refresh must not resurrect config for a CLI the user may have removed.
expect(existsSync(join(home, '.claude'))).toBe(false)
// Why: the statusline script was never installed here, so it must not appear.
expect(existsSync(join(hooksDir, 'claude-statusline.cmd'))).toBe(false)
} finally {
homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir())
rmSync(home, { recursive: true, force: true })
}
})
it('covers every shared launcher script with a refresher', async () => {
const home = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-coverage-'))
homedirMock.mockReturnValue(home)
const previousGrokHome = process.env.GROK_HOME
const previousKimiHome = process.env.KIMI_CODE_HOME
delete process.env.GROK_HOME
delete process.env.KIMI_CODE_HOME
try {
await withPlatform('win32', () => {
for (const [, install] of MANAGED_AGENT_HOOK_INSTALLERS) {
install()
}
})
const hooksDir = join(home, '.orca', 'agent-hooks')
const files = readdirSync(hooksDir)
expect(files.length).toBeGreaterThan(0)
const refresherAgents = MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS.map(([agent]) => agent)
// Why: an installer that writes a shared launcher but skips the refresher list would
// recreate the frozen-stale-script class this suite exists to prevent.
for (const file of files) {
expect(
refresherAgents.some((agent) => file.startsWith(`${agent}-`)),
`${file} is written to ~/.orca/agent-hooks but no refresher owns it`
).toBe(true)
}
// Why: the reverse direction — a refresher naming an agent that writes nothing is a
// stale registry entry, likely a renamed script file.
for (const agent of refresherAgents) {
expect(
files.some((file) => file.startsWith(`${agent}-`)),
`refresher for ${agent} matches no installed script`
).toBe(true)
}
} finally {
homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir())
if (previousGrokHome === undefined) {
delete process.env.GROK_HOME
} else {
process.env.GROK_HOME = previousGrokHome
}
if (previousKimiHome === undefined) {
delete process.env.KIMI_CODE_HOME
} else {
process.env.KIMI_CODE_HOME = previousKimiHome
}
rmSync(home, { recursive: true, force: true })
}
})
it('creates nothing anywhere when no managed scripts exist', async () => {
const home = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-empty-'))
homedirMock.mockReturnValue(home)
try {
await withPlatform('win32', async () => {
for (const [agent, refresh] of MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS) {
await expect(refresh(), `${agent} refresh on empty home`).resolves.toBeUndefined()
}
})
// Why: a refresh pass on a machine with no prior installs must be a strict no-op.
expect(readdirSync(home)).toEqual([])
} finally {
homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir())
rmSync(home, { recursive: true, force: true })
}
})
})
@@ -0,0 +1,90 @@
import { randomUUID } from 'node:crypto'
import { chmod, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { grantDirAclAsync, isPermissionError } from '../win32-utils'
type ExistingScript = { exists: false } | { exists: true; content: string | null }
function isMissingPathError(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
}
async function readExistingScript(scriptPath: string): Promise<ExistingScript> {
try {
return { exists: true, content: await readFile(scriptPath, 'utf-8') }
} catch (error) {
if (isMissingPathError(error)) {
return { exists: false }
}
try {
await stat(scriptPath)
return { exists: true, content: null }
} catch (statError) {
if (isMissingPathError(statError)) {
return { exists: false }
}
throw error
}
}
}
async function scriptStillExists(scriptPath: string): Promise<boolean> {
try {
await stat(scriptPath)
return true
} catch (error) {
if (isMissingPathError(error)) {
return false
}
throw error
}
}
async function writeScriptWithAclRetry(scriptPath: string, content: string): Promise<void> {
try {
await writeFile(scriptPath, content, 'utf-8')
} catch (error) {
if (isPermissionError(error) && process.platform === 'win32') {
try {
await grantDirAclAsync(dirname(scriptPath))
await writeFile(scriptPath, content, 'utf-8')
return
} catch {
// Re-throw the original permission error.
}
}
throw error
}
}
// Why: refresh must not block Electron's main thread or create state for an absent CLI.
export async function refreshManagedScriptIfPresent(
scriptPath: string,
content: string
): Promise<boolean> {
const existing = await readExistingScript(scriptPath)
if (!existing.exists) {
return false
}
if (existing.content === content) {
if (process.platform !== 'win32') {
await chmod(scriptPath, 0o755)
}
return true
}
const tmpPath = join(dirname(scriptPath), `.${Date.now()}-${randomUUID()}.tmp`)
try {
await writeScriptWithAclRetry(tmpPath, content)
if (process.platform !== 'win32') {
await chmod(tmpPath, 0o755)
}
if (!(await scriptStillExists(scriptPath))) {
return false
}
await rename(tmpPath, scriptPath)
return true
} finally {
await rm(tmpPath, { force: true }).catch(() => undefined)
}
}
+13
View File
@@ -25,6 +25,7 @@ import {
writeHooksJsonRemote,
writeManagedScriptRemote
} from '../agent-hooks/installer-utils-remote'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import {
buildPosixHookPayloadCapture,
buildWindowsHookEnvironmentGuardLines,
@@ -296,6 +297,18 @@ function removeInstalledConfig(config: HooksConfig): void {
}
export class AntigravityHookService {
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript())
if (process.platform === 'win32') {
for (const event of ANTIGRAVITY_EVENTS) {
await refreshManagedScriptIfPresent(
getWindowsWrapperScriptPath(event),
getWindowsWrapperScript(event.eventName)
)
}
}
}
getStatus(): AgentHookInstallStatus {
const configPath = getConfigPath()
const scriptPath = getManagedScriptPath()
+7 -33
View File
@@ -1,22 +1,10 @@
import { measureClipboardTextByteLength } from '../../shared/clipboard-text'
import { yieldToEventLoop } from '../../shared/event-loop-yield'
import { getUtf8ChunkEndIndex } from '../../shared/utf8-byte-limits'
import type { CdpCommandSender } from './snapshot-engine'
export const BROWSER_TEXT_INSERT_CHUNK_BYTES = 64 * 1024
function getUtf8ByteLengthForCodePoint(codePoint: number): number {
if (codePoint <= 0x7f) {
return 1
}
if (codePoint <= 0x7ff) {
return 2
}
if (codePoint <= 0xffff) {
return 3
}
return 4
}
export function splitBrowserTextInsertionChunks(
text: string,
maxChunkBytes = BROWSER_TEXT_INSERT_CHUNK_BYTES
@@ -38,24 +26,11 @@ export function* iterateBrowserTextInsertionChunks(
return
}
let currentStart = 0
let currentBytes = 0
let index = 0
while (index < text.length) {
const codePoint = text.codePointAt(index) ?? 0
const codeUnitLength = codePoint > 0xffff ? 2 : 1
const nextIndex = index + codeUnitLength
const characterBytes = getUtf8ByteLengthForCodePoint(codePoint)
if (currentBytes > 0 && currentBytes + characterBytes > normalizedMax) {
yield text.slice(currentStart, index)
currentStart = index
currentBytes = 0
}
currentBytes += characterBytes
index = nextIndex
}
if (currentStart < text.length) {
yield text.slice(currentStart)
let startIndex = 0
while (startIndex < text.length) {
const endIndex = getUtf8ChunkEndIndex(text, startIndex, normalizedMax)
yield text.slice(startIndex, endIndex)
startIndex = endIndex
}
}
@@ -68,8 +43,7 @@ export async function insertTextThroughCdp(
let chunk = chunks.next()
while (!chunk.done) {
await sender('Input.insertText', { text: chunk.value })
// Why: browser automation text can be paste-sized; yielding keeps the main
// process responsive between bounded CDP payloads.
// Keep paste-sized insertion from monopolizing the main process.
chunk = chunks.next()
if (options?.yieldBetweenChunks !== false && !chunk.done) {
await yieldToEventLoop()
+25 -122
View File
@@ -1,20 +1,12 @@
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import type * as FsPromises from 'node:fs/promises'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClaudeUsagePersistedState } from './types'
import type * as Scanner from './scanner'
const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({
getPathMock: vi.fn(() => '/tmp/orca-test-userdata'),
// Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting
// every open would hide a regression back to multiple full-cache rewrites per scan.
writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 },
writeGate: {
blocked: false,
waiters: [] as (() => void)[]
}
const { getPathMock } = vi.hoisted(() => ({
getPathMock: vi.fn(() => '/tmp/orca-test-userdata')
}))
vi.mock('electron', () => ({
@@ -23,29 +15,6 @@ vi.mock('electron', () => ({
}
}))
vi.mock('node:fs/promises', async () => {
const actual = await vi.importActual<typeof FsPromises>('node:fs/promises')
return {
...actual,
open: (async (...args: Parameters<typeof actual.open>) => {
if (args[1] !== 'w') {
return actual.open(...args)
}
writeOpens.value += 1
writeOpens.inFlight += 1
writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight)
try {
if (writeGate.blocked) {
await new Promise<void>((resolve) => writeGate.waiters.push(resolve))
}
return await actual.open(...args)
} finally {
writeOpens.inFlight -= 1
}
}) as typeof actual.open
}
})
vi.mock('./scanner', async (importOriginal) => ({
...(await importOriginal<typeof Scanner>()),
scanClaudeUsageFiles: vi.fn()
@@ -54,12 +23,15 @@ vi.mock('./scanner', async (importOriginal) => ({
import { ClaudeUsageStore, initClaudeUsagePath } from './store'
import { scanClaudeUsageFiles } from './scanner'
function createStoreWithState(state: Partial<ClaudeUsagePersistedState>): ClaudeUsageStore {
const store = new ClaudeUsageStore({
function createBackingStore(): ConstructorParameters<typeof ClaudeUsageStore>[0] {
return {
getRepos: () => [],
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined
} as never)
getAllWorktreeMeta: () => ({})
}
}
function createStoreWithState(state: Partial<ClaudeUsagePersistedState>): ClaudeUsageStore {
const store = new ClaudeUsageStore(createBackingStore())
;(store as unknown as { state: ClaudeUsagePersistedState }).state = {
schemaVersion: 1,
@@ -86,11 +58,6 @@ describe('ClaudeUsageStore', () => {
tempUserData = mkdtempSync(join(tmpdir(), 'orca-claude-usage-store-'))
getPathMock.mockReturnValue(tempUserData)
initClaudeUsagePath()
writeOpens.value = 0
writeOpens.inFlight = 0
writeOpens.maxConcurrent = 0
writeGate.blocked = false
writeGate.waiters = []
vi.mocked(scanClaudeUsageFiles).mockReset()
vi.mocked(scanClaudeUsageFiles).mockResolvedValue({
processedFiles: [],
@@ -106,6 +73,17 @@ describe('ClaudeUsageStore', () => {
rmSync(tempUserData, { recursive: true, force: true })
})
it('defaults a null legacy opt-in while invalidating the cache', () => {
writeFileSync(
join(tempUserData, 'orca-claude-usage.json'),
JSON.stringify({ schemaVersion: 4, scanState: { enabled: null } })
)
const store = new ClaudeUsageStore(createBackingStore())
expect(store.getScanState().enabled).toBe(false)
})
it('reports no data for Orca scope when only non-Orca usage exists', async () => {
const store = createStoreWithState({
sessions: [
@@ -617,64 +595,7 @@ describe('ClaudeUsageStore', () => {
expect(refreshMock).toHaveBeenCalledWith(false)
})
it('persists setEnabled via async durable write without leaving tmp files', async () => {
const store = createStoreWithState({
schemaVersion: 5,
scanState: {
enabled: false,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
}
})
await store.setEnabled(true)
expect(writeOpens.value).toBe(1)
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
const persisted = JSON.parse(
readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')
)
expect(persisted.scanState.enabled).toBe(true)
// Pretty-print preserved for human inspection of the analytics cache.
expect(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).toContain('\n')
})
it('vetoes a stale concurrent async write so the newer snapshot wins', async () => {
const store = createStoreWithState({
schemaVersion: 5,
scanState: {
enabled: true,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
}
})
const internals = store as unknown as {
writeToDisk: () => Promise<void>
state: ClaudeUsagePersistedState
}
writeGate.blocked = true
const first = internals.writeToDisk()
await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1))
internals.state.scanState.enabled = false
writeGate.blocked = false
const second = internals.writeToDisk()
writeGate.waiters.splice(0).forEach((resolve) => resolve())
await Promise.all([first, second])
expect(
JSON.parse(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).scanState
.enabled
).toBe(false)
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
// Serialized, so the superseded write can be skipped safely rather than racing the newer one.
expect(writeOpens.maxConcurrent).toBe(1)
})
it('persists a successful refresh with one full-cache write', async () => {
it('adapts Claude scans to pretty-printed cache persistence', async () => {
const store = createStoreWithState({
schemaVersion: 5,
scanState: {
@@ -687,25 +608,7 @@ describe('ClaudeUsageStore', () => {
await store.refresh(true)
// Why exactly one: scan start used to rewrite the whole 20 MB cache before any result changed.
expect(writeOpens.value).toBe(1)
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
expect(
JSON.parse(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).scanState
).toMatchObject({
lastScanStartedAt: new Date('2026-04-09T12:00:00.000-04:00').getTime(),
lastScanCompletedAt: new Date('2026-04-09T12:00:00.000-04:00').getTime(),
lastScanError: null
})
})
it('sweeps a usage temp file orphaned by a crash between write and rename', async () => {
const orphan = join(tempUserData, 'orca-claude-usage.json.999.1.abc.tmp')
writeFileSync(orphan, '{}')
createStoreWithState({})
await vi.waitFor(() =>
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
)
expect(scanClaudeUsageFiles).toHaveBeenCalledWith([], [])
expect(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).toContain('\n')
})
})
+37 -187
View File
@@ -1,14 +1,11 @@
/* eslint-disable max-lines -- Why: this store is the single main-process owner for Claude usage persistence, scan gating, and query semantics. Keeping those policy decisions together avoids split-brain range/scope logic across multiple files. */
/* eslint-disable max-lines -- Why: Claude pricing, range, scope, breakdown, and automation-attribution policies remain one cohesive store. */
import { app } from 'electron'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer'
import type {
ClaudeUsageBreakdownKind,
ClaudeUsageBreakdownRow,
ClaudeUsageDailyPoint,
ClaudeUsageRange,
ClaudeUsageScanState,
ClaudeUsageScope,
ClaudeUsageSessionRow,
ClaudeUsageSnapshot,
@@ -16,16 +13,15 @@ import type {
} from '../../shared/claude-usage-types'
import type { AutomationRunUsage } from '../../shared/automations-types'
import type { Store } from '../persistence'
import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata'
import type { ClaudeUsagePersistedState } from './types'
import { createWorktreeRefs } from '../usage/usage-worktree-refs'
import { getSessionProjectLabel, scanClaudeUsageFiles } from './scanner'
import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range'
import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle'
// Why: v5 widens Claude ownership keys (message-id / uuid fallbacks). Older
// caches either lack ownership or used narrower keys and can under/over-count
// after fork reclaim (#8006).
const SCHEMA_VERSION = 5
const STALE_MS = 5 * 60_000
const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000
// Why: capture the path after configureDevUserDataPath() but before app.setName()
@@ -130,6 +126,21 @@ function getDefaultState(): ClaudeUsagePersistedState {
}
}
function normalizePersistedState(state: ClaudeUsagePersistedState): ClaudeUsagePersistedState {
if (state.schemaVersion === SCHEMA_VERSION) {
return state
}
// Scanner changes invalidate totals, but preserving enabled keeps existing tracking on.
const defaults = getDefaultState()
return {
...defaults,
scanState: {
...defaults.scanState,
enabled: state.scanState.enabled ?? defaults.scanState.enabled
}
}
}
export function initClaudeUsagePath(): void {
_claudeUsageFile = join(app.getPath('userData'), 'orca-claude-usage.json')
}
@@ -288,120 +299,22 @@ function estimateCostUsd(
)
}
function getRangeCutoff(range: ClaudeUsageRange): string | null {
if (range === 'all') {
return null
}
const days = range === '7d' ? 7 : range === '30d' ? 30 : 90
const now = new Date()
now.setHours(0, 0, 0, 0)
now.setDate(now.getDate() - (days - 1))
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function getLocalDay(timestamp: string): string | null {
const parsed = new Date(timestamp)
if (Number.isNaN(parsed.getTime())) {
return null
}
const year = parsed.getFullYear()
const month = String(parsed.getMonth() + 1).padStart(2, '0')
const day = String(parsed.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function getWorktreeFingerprint(worktreesByRepo: Map<string, UsageWorktreeRef[]>): string {
const rows = [...worktreesByRepo.entries()]
.flatMap(([repoId, worktrees]) =>
worktrees.map((worktree) =>
JSON.stringify({
repoId,
worktreeId: worktree.worktreeId,
path: worktree.path,
displayName: worktree.displayName
})
)
)
.sort()
return JSON.stringify(rows)
}
export class ClaudeUsageStore {
private state: ClaudeUsagePersistedState
private readonly store: Store
private scanPromise: Promise<void> | null = null
// Why: the 20 MB usage JSON must not block the Electron main thread; the writer serializes writes
// and vetoes superseded renames.
private readonly writer = new UsageCacheSnapshotWriter('[claude-usage]', getClaudeUsageFile)
constructor(store: Store) {
this.store = store
this.state = this.load()
}
private load(): ClaudeUsagePersistedState {
try {
const usageFile = getClaudeUsageFile()
if (!existsSync(usageFile)) {
return getDefaultState()
}
const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as ClaudeUsagePersistedState
if (parsed.schemaVersion !== SCHEMA_VERSION) {
// Why: scanner semantics affect persisted totals, so old Claude caches
// must be rebuilt after parser/source changes instead of reused briefly.
// Preserve scanState.enabled so existing users keep tracking on across
// schema bumps; the next refresh will repopulate the analytics.
const defaults = getDefaultState()
return {
...defaults,
scanState: {
...defaults.scanState,
enabled: parsed.scanState?.enabled ?? defaults.scanState.enabled
}
}
}
return {
...getDefaultState(),
...parsed,
scanState: {
...getDefaultState().scanState,
...parsed.scanState
}
}
} catch (error) {
// Why: Claude usage is a local analytics feature, not primary workspace
// state. A corrupt cache should degrade to a fresh rebuild instead of
// preventing Orca from booting, but we leave the file on disk for debugging.
console.error('[claude-usage] Failed to load persisted state, starting fresh:', error)
return getDefaultState()
}
}
private writeToDisk(): Promise<void> {
// Pretty-print preserved: humans inspect this analytics cache on disk.
return this.writer.write(() => JSON.stringify(this.state, null, 2))
}
/** Await queued cache writes so quit does not drop the final snapshot. */
flush(): Promise<void> {
return this.writer.flush()
}
async setEnabled(enabled: boolean): Promise<ClaudeUsageScanState> {
this.state.scanState.enabled = enabled
await this.writeToDisk()
return this.getScanState()
}
getScanState(): ClaudeUsageScanState {
return {
...this.state.scanState,
isScanning: this.scanPromise !== null,
hasAnyClaudeData: this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0
}
export class ClaudeUsageStore extends UsageProviderStoreLifecycle<
'processedFiles',
ClaudeUsagePersistedState,
'hasAnyClaudeData'
> {
constructor(store: Pick<Store, 'getRepos' | 'getAllWorktreeMeta'>) {
super(store, {
logTag: '[claude-usage]',
resolveCacheFile: getClaudeUsageFile,
createDefaultState: getDefaultState,
normalizeState: normalizePersistedState,
sourceKey: 'processedFiles',
dataPresenceKey: 'hasAnyClaudeData',
jsonIndent: 2,
scan: scanClaudeUsageFiles
})
}
getSnapshot(
@@ -419,63 +332,6 @@ export class ClaudeUsageStore {
}
}
async refresh(force = false): Promise<ClaudeUsageScanState> {
if (!this.state.scanState.enabled) {
return this.getScanState()
}
const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint()
if (!force && this.state.scanState.lastScanCompletedAt) {
const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt
if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) {
return this.getScanState()
}
}
await this.runScan()
return this.getScanState()
}
private async runScan(): Promise<void> {
if (this.scanPromise) {
await this.scanPromise
return
}
this.state.scanState.lastScanStartedAt = Date.now()
this.state.scanState.lastScanError = null
// Why no write here: persisting scan-start would rewrite the whole multi-MB cache before a single
// result changed. The completion/failure write below persists the same fields.
// Why: assign scanPromise before any await so concurrent refresh shares one scan.
this.scanPromise = (async () => {
try {
const repos = this.store.getRepos()
const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo)
const result = await scanClaudeUsageFiles(
createWorktreeRefs(repos, worktreesByRepo),
this.state.worktreeFingerprint === worktreeFingerprint ? this.state.processedFiles : []
)
this.state.processedFiles = result.processedFiles
this.state.sessions = result.sessions
this.state.dailyAggregates = result.dailyAggregates
this.state.worktreeFingerprint = worktreeFingerprint
this.state.scanState.lastScanCompletedAt = Date.now()
this.state.scanState.lastScanError = null
// Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into
// a scan error and reject refresh() for every query caller; writeToDisk already logs it.
await this.writeToDisk().catch(() => {})
} catch (error) {
this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error)
await this.writeToDisk().catch(() => {})
} finally {
this.scanPromise = null
}
})()
await this.scanPromise
}
async getSummary(scope: ClaudeUsageScope, range: ClaudeUsageRange): Promise<ClaudeUsageSummary> {
await this.refresh(false)
return this.buildSummary(scope, range)
@@ -843,7 +699,7 @@ export class ClaudeUsageStore {
}
private getFilteredDaily(scope: ClaudeUsageScope, range: ClaudeUsageRange) {
const cutoff = getRangeCutoff(range)
const cutoff = getUsageRangeCutoff(range)
return this.state.dailyAggregates.filter((entry) => {
if (cutoff && entry.day < cutoff) {
return false
@@ -856,12 +712,12 @@ export class ClaudeUsageStore {
}
private getFilteredSessions(scope: ClaudeUsageScope, range: ClaudeUsageRange) {
const cutoff = getRangeCutoff(range)
const cutoff = getUsageRangeCutoff(range)
return this.state.sessions.filter((session) => {
// Why: daily aggregates use local calendar days, so session filtering has
// to use the same conversion or the sessions table/counts can disagree
// with the chart around UTC day boundaries.
const day = getLocalDay(session.lastTimestamp)
const day = getLocalUsageDay(session.lastTimestamp)
if (!day) {
return false
}
@@ -883,10 +739,4 @@ export class ClaudeUsageStore {
Boolean(lastScanError) || lastScanCompletedAt === null || lastScanCompletedAt < completedAt
)
}
private async getCurrentWorktreeFingerprint(): Promise<string> {
const repos = this.store.getRepos()
const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
return getWorktreeFingerprint(worktreesByRepo)
}
}
+13
View File
@@ -13,6 +13,7 @@ import {
writeHooksJsonRemote,
writeManagedScriptRemote
} from '../agent-hooks/installer-utils-remote'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import {
buildPosixHookPayloadCapture,
buildWindowsHookEnvironmentGuardLines,
@@ -171,6 +172,18 @@ export class ClaudeHookService {
return { agent: this.options.agent, state, configPath, managedHooksPresent, detail }
}
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(
getManagedScriptPath(this.options.settings),
getManagedScript('local', { skipWhenDevinImportsClaude: this.options.agent === 'claude' })
)
// Why: no agent gate — the statusline script only ever exists for claude, so presence is the gate.
await refreshManagedScriptIfPresent(
getStatusLineScriptPath(this.options.settings),
getManagedStatusLineScript('local')
)
}
install(): AgentHookInstallStatus {
const configPath = getConfigPath(this.options.settings)
const scriptPath = getManagedScriptPath(this.options.settings)
+1 -1
View File
@@ -489,7 +489,7 @@ describe('CliInstaller', () => {
const registryReader = new WindowsUserPathRegistryReader({
platform: 'win32',
registryLoader: async () => ({
HK: { CU: 0x80000001 },
HK: { CU: 0x80000001, LM: 0x80000002 },
getRegistryKey: () => ({
Path: { name: 'Path', type: 2, value: registryPath }
})
@@ -7,7 +7,7 @@ import {
function registryModule(pathValue?: string, type = 2) {
return {
HK: { CU: 0x80000001 },
HK: { CU: 0x80000001, LM: 0x80000002 },
REG: { SZ: 1, EXPAND_SZ: 2 },
getRegistryKey: vi.fn(() =>
pathValue === undefined ? {} : { Path: { name: 'Path', type, value: pathValue } }
+11 -30
View File
@@ -1,26 +1,17 @@
import { createRequire } from 'node:module'
import {
loadWindowsNativeRegistry,
WINDOWS_REG_EXPAND_SZ,
WINDOWS_REG_SZ,
type WindowsNativeRegistryModule
} from '../windows-native-registry'
export type WindowsUserPathReadResult =
| { state: 'success'; value: string | null; expandable: boolean }
| { state: 'unknown'; detail: string }
type RegistryValue = {
name?: unknown
type?: unknown
value?: unknown
}
type WindowsRegistryModule = {
HK: { CU: number }
getRegistryKey: (
root: number,
path: string
) => Record<string, RegistryValue | undefined> | null | undefined
}
type WindowsUserPathRegistryReaderOptions = {
platform?: NodeJS.Platform
registryLoader?: () => Promise<WindowsRegistryModule>
registryLoader?: () => Promise<WindowsNativeRegistryModule>
now?: () => number
cacheTtlMs?: number
}
@@ -28,19 +19,9 @@ type WindowsUserPathRegistryReaderOptions = {
const DEFAULT_CACHE_TTL_MS = 1_000
const USER_ENVIRONMENT_KEY = 'Environment'
const USER_PATH_VALUE = 'Path'
const REG_SZ = 1
const REG_EXPAND_SZ = 2
const requireFromMain = createRequire(__filename)
async function loadWindowsRegistryModule(): Promise<WindowsRegistryModule> {
// Why: the optional dependency is not present in non-Windows installs, so
// TypeScript and the main bundle must not resolve it eagerly.
return requireFromMain('windows-native-registry') as WindowsRegistryModule
}
export class WindowsUserPathRegistryReader {
private readonly platform: NodeJS.Platform
private readonly registryLoader: () => Promise<WindowsRegistryModule>
private readonly registryLoader: () => Promise<WindowsNativeRegistryModule>
private readonly now: () => number
private readonly cacheTtlMs: number
private cached: { readAt: number; result: WindowsUserPathReadResult } | null = null
@@ -49,7 +30,7 @@ export class WindowsUserPathRegistryReader {
constructor(options: WindowsUserPathRegistryReaderOptions = {}) {
this.platform = options.platform ?? process.platform
this.registryLoader = options.registryLoader ?? loadWindowsRegistryModule
this.registryLoader = options.registryLoader ?? (async () => loadWindowsNativeRegistry())
this.now = options.now ?? Date.now
this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS
}
@@ -123,7 +104,7 @@ export class WindowsUserPathRegistryReader {
return { state: 'success', value: null, expandable: false }
}
if (
(pathEntry.type !== REG_SZ && pathEntry.type !== REG_EXPAND_SZ) ||
(pathEntry.type !== WINDOWS_REG_SZ && pathEntry.type !== WINDOWS_REG_EXPAND_SZ) ||
typeof pathEntry.value !== 'string'
) {
return {
@@ -134,7 +115,7 @@ export class WindowsUserPathRegistryReader {
return {
state: 'success',
value: pathEntry.value || null,
expandable: pathEntry.type === REG_EXPAND_SZ
expandable: pathEntry.type === WINDOWS_REG_EXPAND_SZ
}
} catch {
return {
@@ -16,8 +16,8 @@ vi.mock('electron', () => ({
function createStoreWithState(state: CodexUsagePersistedState): CodexUsageStore {
const store = new CodexUsageStore({
getRepos: () => [],
getWorktreeMeta: () => undefined
} as never)
getAllWorktreeMeta: () => ({})
})
;(store as unknown as { state: CodexUsagePersistedState }).state = state
return store
+9 -154
View File
@@ -1,25 +1,12 @@
/* eslint-disable max-lines */
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import type * as FsPromises from 'node:fs/promises'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
CodexUsageDailyAggregate,
CodexUsagePersistedFile,
CodexUsagePersistedState,
CodexUsageSession
} from './types'
import type { CodexUsagePersistedState } from './types'
const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({
getPathMock: vi.fn(() => '/tmp/orca-test-userdata'),
// Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting
// every open would hide a regression back to multiple full-cache rewrites per scan.
writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 },
writeGate: {
blocked: false,
waiters: [] as (() => void)[]
}
const { getPathMock } = vi.hoisted(() => ({
getPathMock: vi.fn(() => '/tmp/orca-test-userdata')
}))
vi.mock('electron', () => ({
@@ -28,29 +15,6 @@ vi.mock('electron', () => ({
}
}))
vi.mock('node:fs/promises', async () => {
const actual = await vi.importActual<typeof FsPromises>('node:fs/promises')
return {
...actual,
open: (async (...args: Parameters<typeof actual.open>) => {
if (args[1] !== 'w') {
return actual.open(...args)
}
writeOpens.value += 1
writeOpens.inFlight += 1
writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight)
try {
if (writeGate.blocked) {
await new Promise<void>((resolve) => writeGate.waiters.push(resolve))
}
return await actual.open(...args)
} finally {
writeOpens.inFlight -= 1
}
}) as typeof actual.open
}
})
vi.mock('./scanner', () => ({
scanCodexUsageFiles: vi.fn()
}))
@@ -58,27 +22,7 @@ vi.mock('./scanner', () => ({
import { CodexUsageStore, initCodexUsagePath, normalizePersistedState } from './store'
import { scanCodexUsageFiles } from './scanner'
type ScanResult = {
processedFiles: CodexUsagePersistedFile[]
sessions: CodexUsageSession[]
dailyAggregates: CodexUsageDailyAggregate[]
}
function createDeferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
reject: (reason?: unknown) => void
} {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve
reject = promiseReject
})
return { promise, resolve, reject }
}
function createEmptyScanResult(): ScanResult {
function createEmptyScanResult() {
return {
processedFiles: [],
sessions: [],
@@ -89,9 +33,8 @@ function createEmptyScanResult(): ScanResult {
function createStoreWithState(state: Partial<CodexUsagePersistedState>): CodexUsageStore {
const store = new CodexUsageStore({
getRepos: () => [],
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined
} as never)
getAllWorktreeMeta: () => ({})
})
;(store as unknown as { state: CodexUsagePersistedState }).state = {
schemaVersion: 1,
@@ -118,11 +61,6 @@ describe('CodexUsageStore', () => {
tempUserData = mkdtempSync(join(tmpdir(), 'orca-codex-usage-store-'))
getPathMock.mockReturnValue(tempUserData)
initCodexUsagePath()
writeOpens.value = 0
writeOpens.inFlight = 0
writeOpens.maxConcurrent = 0
writeGate.blocked = false
writeGate.waiters = []
vi.mocked(scanCodexUsageFiles).mockReset()
vi.mocked(scanCodexUsageFiles).mockResolvedValue(createEmptyScanResult())
vi.useFakeTimers()
@@ -134,7 +72,7 @@ describe('CodexUsageStore', () => {
rmSync(tempUserData, { recursive: true, force: true })
})
it('persists a successful refresh with one compact async disk write', async () => {
it('adapts Codex scans to compact cache persistence', async () => {
const store = createStoreWithState({
schemaVersion: 5,
scanState: {
@@ -147,92 +85,9 @@ describe('CodexUsageStore', () => {
await store.refresh(true)
// Why exactly one: a refresh that rewrites the whole 60 MB cache twice is the regression this guards.
expect(writeOpens.value).toBe(1)
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
const persistedJson = readFileSync(join(tempUserData, 'orca-codex-usage.json'), 'utf-8')
expect(scanCodexUsageFiles).toHaveBeenCalledWith([], [])
expect(persistedJson).toBe(JSON.stringify(JSON.parse(persistedJson)))
expect(persistedJson).not.toContain('\n')
expect(JSON.parse(persistedJson).scanState).toMatchObject({
enabled: true,
lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(),
lastScanCompletedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(),
lastScanError: null
})
})
it('keeps scan start visible in memory while scan-start persistence is skipped', async () => {
const pendingScan = createDeferred<ScanResult>()
vi.mocked(scanCodexUsageFiles).mockReturnValueOnce(pendingScan.promise)
const store = createStoreWithState({
schemaVersion: 5,
scanState: {
enabled: true,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: 'previous failure'
}
})
const refreshPromise = store.refresh(true)
await Promise.resolve()
expect(store.getScanState()).toMatchObject({
isScanning: true,
lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(),
lastScanError: null
})
expect(writeOpens.value).toBe(0)
pendingScan.resolve(createEmptyScanResult())
await refreshPromise
expect(store.getScanState().isScanning).toBe(false)
expect(writeOpens.value).toBe(1)
})
it('vetoes a stale concurrent async write so the newer snapshot wins without leaking tmp files', async () => {
const store = createStoreWithState({
schemaVersion: 5,
scanState: {
enabled: true,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
}
})
const internals = store as unknown as {
writeToDisk: () => Promise<void>
state: CodexUsagePersistedState
}
writeGate.blocked = true
const first = internals.writeToDisk()
await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1))
internals.state.scanState.enabled = false
writeGate.blocked = false
const second = internals.writeToDisk()
writeGate.waiters.splice(0).forEach((resolve) => resolve())
await Promise.all([first, second])
expect(
JSON.parse(readFileSync(join(tempUserData, 'orca-codex-usage.json'), 'utf-8')).scanState
.enabled
).toBe(false)
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
// Serialized, so the superseded write can be skipped safely rather than racing the newer one.
expect(writeOpens.maxConcurrent).toBe(1)
})
it('sweeps a usage temp file orphaned by a crash between write and rename', async () => {
const orphan = join(tempUserData, 'orca-codex-usage.json.999.1.abc.tmp')
writeFileSync(orphan, '{}')
createStoreWithState({})
await vi.waitFor(() =>
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
)
})
it('reports no data for Orca scope when only non-Orca Codex usage exists', async () => {
+21 -168
View File
@@ -1,14 +1,11 @@
/* eslint-disable max-lines -- Why: this store owns Codex analytics persistence, scan policy, and renderer query semantics. Keeping them together prevents the Codex range/scope rules from drifting away from the scanner’s event model. */
/* eslint-disable max-lines -- Why: Codex pricing, range, scope, breakdown, and automation-attribution policies remain one cohesive store. */
import { app } from 'electron'
import { join } from 'node:path'
import { existsSync, readFileSync } from 'node:fs'
import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer'
import type {
CodexUsageBreakdownKind,
CodexUsageBreakdownRow,
CodexUsageDailyPoint,
CodexUsageRange,
CodexUsageScanState,
CodexUsageScope,
CodexUsageSessionRow,
CodexUsageSnapshot,
@@ -16,13 +13,12 @@ import type {
} from '../../shared/codex-usage-types'
import type { AutomationRunUsage } from '../../shared/automations-types'
import type { Store } from '../persistence'
import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata'
import type { CodexUsagePersistedState } from './types'
import { createWorktreeRefs } from '../usage/usage-worktree-refs'
import { CODEX_USAGE_SCHEMA_VERSION, codexUsageProvider } from './codex-usage-provider'
import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range'
import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle'
const SCHEMA_VERSION = CODEX_USAGE_SCHEMA_VERSION
const STALE_MS = 5 * 60_000
const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000
let _codexUsageFile: string | null = null
@@ -308,31 +304,6 @@ function estimateCostUsd(
)
}
function getRangeCutoff(range: CodexUsageRange): string | null {
if (range === 'all') {
return null
}
const days = range === '7d' ? 7 : range === '30d' ? 30 : 90
const now = new Date()
now.setHours(0, 0, 0, 0)
now.setDate(now.getDate() - (days - 1))
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function getLocalDay(timestamp: string): string | null {
const parsed = new Date(timestamp)
if (Number.isNaN(parsed.getTime())) {
return null
}
const year = parsed.getFullYear()
const month = String(parsed.getMonth() + 1).padStart(2, '0')
const day = String(parsed.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
type ScopedCodexUsageModelRow = {
modelKey: string
modelLabel: string
@@ -345,78 +316,21 @@ type ScopedCodexUsageModelRow = {
totalTokens: number
}
function getWorktreeFingerprint(worktreesByRepo: Map<string, UsageWorktreeRef[]>): string {
const rows = [...worktreesByRepo.entries()]
.flatMap(([repoId, worktrees]) =>
worktrees.map((worktree) =>
JSON.stringify({
repoId,
worktreeId: worktree.worktreeId,
path: worktree.path,
displayName: worktree.displayName
})
)
)
.sort()
return JSON.stringify(rows)
}
export class CodexUsageStore {
private state: CodexUsagePersistedState
private readonly store: Store
private scanPromise: Promise<void> | null = null
// Why: the 60 MB usage JSON must not block the Electron main thread; the writer serializes writes
// and vetoes superseded renames.
private readonly writer = new UsageCacheSnapshotWriter('[codex-usage]', getCodexUsageFile)
constructor(store: Store) {
this.store = store
this.state = this.load()
}
private load(): CodexUsagePersistedState {
try {
const usageFile = getCodexUsageFile()
if (!existsSync(usageFile)) {
return getDefaultState()
}
const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as CodexUsagePersistedState
return normalizePersistedState({
...getDefaultState(),
...parsed,
scanState: {
...getDefaultState().scanState,
...parsed.scanState
}
})
} catch (error) {
console.error('[codex-usage] Failed to load persisted state, starting fresh:', error)
return getDefaultState()
}
}
private writeToDisk(): Promise<void> {
// Compact: this cache reaches 60 MB, and pretty-printing it costs main-thread time per scan.
return this.writer.write(() => JSON.stringify(this.state))
}
/** Await queued cache writes so quit does not drop the final snapshot. */
flush(): Promise<void> {
return this.writer.flush()
}
async setEnabled(enabled: boolean): Promise<CodexUsageScanState> {
this.state.scanState.enabled = enabled
await this.writeToDisk()
return this.getScanState()
}
getScanState(): CodexUsageScanState {
return {
...this.state.scanState,
isScanning: this.scanPromise !== null,
hasAnyCodexData: this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0
}
export class CodexUsageStore extends UsageProviderStoreLifecycle<
'processedFiles',
CodexUsagePersistedState,
'hasAnyCodexData'
> {
constructor(store: Pick<Store, 'getRepos' | 'getAllWorktreeMeta'>) {
super(store, {
logTag: '[codex-usage]',
resolveCacheFile: getCodexUsageFile,
createDefaultState: getDefaultState,
normalizeState: normalizePersistedState,
sourceKey: 'processedFiles',
dataPresenceKey: 'hasAnyCodexData',
scan: codexUsageProvider.scan
})
}
getSnapshot(
@@ -434,61 +348,6 @@ export class CodexUsageStore {
}
}
async refresh(force = false): Promise<CodexUsageScanState> {
if (!this.state.scanState.enabled) {
return this.getScanState()
}
const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint()
if (!force && this.state.scanState.lastScanCompletedAt) {
const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt
if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) {
return this.getScanState()
}
}
await this.runScan()
return this.getScanState()
}
private async runScan(): Promise<void> {
if (this.scanPromise) {
await this.scanPromise
return
}
this.state.scanState.lastScanStartedAt = Date.now()
this.state.scanState.lastScanError = null
// Why no write here: persisting scan-start would rewrite the whole multi-MB cache before a single
// result changed. The completion/failure write below persists the same fields.
this.scanPromise = (async () => {
try {
const repos = this.store.getRepos()
const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo)
const result = await codexUsageProvider.scan(
createWorktreeRefs(repos, worktreesByRepo),
this.state.worktreeFingerprint === worktreeFingerprint ? this.state.processedFiles : []
)
this.state.processedFiles = result.processedFiles
this.state.sessions = result.sessions
this.state.dailyAggregates = result.dailyAggregates
this.state.worktreeFingerprint = worktreeFingerprint
this.state.scanState.lastScanCompletedAt = Date.now()
this.state.scanState.lastScanError = null
// Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into
// a scan error and reject refresh() for every query caller; writeToDisk already logs it.
await this.writeToDisk().catch(() => {})
} catch (error) {
this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error)
await this.writeToDisk().catch(() => {})
} finally {
this.scanPromise = null
}
})()
await this.scanPromise
}
async getSummary(scope: CodexUsageScope, range: CodexUsageRange): Promise<CodexUsageSummary> {
await this.refresh(false)
return this.buildSummary(scope, range)
@@ -888,7 +747,7 @@ export class CodexUsageStore {
}
private getFilteredDaily(scope: CodexUsageScope, range: CodexUsageRange) {
const cutoff = getRangeCutoff(range)
const cutoff = getUsageRangeCutoff(range)
return this.state.dailyAggregates.filter((entry) => {
if (cutoff && entry.day < cutoff) {
return false
@@ -901,9 +760,9 @@ export class CodexUsageStore {
}
private getFilteredSessions(scope: CodexUsageScope, range: CodexUsageRange) {
const cutoff = getRangeCutoff(range)
const cutoff = getUsageRangeCutoff(range)
return this.state.sessions.filter((session) => {
const day = getLocalDay(session.lastTimestamp)
const day = getLocalUsageDay(session.lastTimestamp)
if (!day) {
return false
}
@@ -975,10 +834,4 @@ export class CodexUsageStore {
Boolean(lastScanError) || lastScanCompletedAt === null || lastScanCompletedAt < completedAt
)
}
private async getCurrentWorktreeFingerprint(): Promise<string> {
const repos = this.store.getRepos()
const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
return getWorktreeFingerprint(worktreesByRepo)
}
}
+5
View File
@@ -19,6 +19,7 @@ import {
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path'
import { writeFileAtomically } from '../codex-accounts/fs-utils'
import {
@@ -1021,6 +1022,10 @@ function getWslReconciliationKey(runtimeHomePath: string): string {
}
export class CodexHookService {
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript())
}
private readonly wslReconciliationGeneration = new Map<string, number>()
private supersedeWslReconciliation(runtimeHomePath: string | null | undefined): number {
+5
View File
@@ -14,6 +14,7 @@ import {
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import {
readHooksJsonRemote,
writeHooksJsonRemote,
@@ -88,6 +89,10 @@ function buildInstalledConfig(
}
export class CommandCodeHookService {
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(getManagedScriptPath(), buildCommandCodeManagedScript())
}
getStatus(): AgentHookInstallStatus {
const configPath = getConfigPath()
const scriptPath = getManagedScriptPath()
-59
View File
@@ -1,59 +0,0 @@
import { describe, expect, it } from 'vitest'
import { parseKey } from './key-mapping'
describe('parseKey', () => {
it.each([
['a', { key: 'a', modifiers: [] }],
['7', { key: '7', modifiers: [] }],
[';', { key: ';', modifiers: [] }],
['+', { key: '+', modifiers: [] }],
['Return', { key: 'Enter', modifiers: [] }],
['Escape', { key: 'Escape', modifiers: [] }],
['BackSpace', { key: 'Backspace', modifiers: [] }],
['Tab', { key: 'Tab', modifiers: [] }],
['space', { key: 'Space', modifiers: [] }],
['Page_Up', { key: 'PageUp', modifiers: [] }],
['Page_Down', { key: 'PageDown', modifiers: [] }],
['Home', { key: 'Home', modifiers: [] }],
['End', { key: 'End', modifiers: [] }],
['Up', { key: 'ArrowUp', modifiers: [] }],
['Down', { key: 'ArrowDown', modifiers: [] }],
['Left', { key: 'ArrowLeft', modifiers: [] }],
['Right', { key: 'ArrowRight', modifiers: [] }],
['Delete', { key: 'Delete', modifiers: [] }],
['Insert', { key: 'Insert', modifiers: [] }],
['F1', { key: 'F1', modifiers: [] }],
['F24', { key: 'F24', modifiers: [] }],
['KP_0', { key: 'Numpad0', modifiers: [] }],
['KP_9', { key: 'Numpad9', modifiers: [] }],
['KP_Add', { key: 'NumpadAdd', modifiers: [] }],
['KP_Subtract', { key: 'NumpadSubtract', modifiers: [] }],
['KP_Multiply', { key: 'NumpadMultiply', modifiers: [] }],
['KP_Divide', { key: 'NumpadDivide', modifiers: [] }],
['KP_Enter', { key: 'NumpadEnter', modifiers: [] }]
])('maps %s', (input, expected) => {
expect(parseKey(input)).toEqual(expected)
})
it.each([
['ctrl+a', { key: 'a', modifiers: ['Ctrl'] }],
['control+a', { key: 'a', modifiers: ['Ctrl'] }],
['ctrl+shift+t', { key: 't', modifiers: ['Ctrl', 'Shift'] }],
['alt+F4', { key: 'F4', modifiers: ['Alt'] }],
['cmd+a', { key: 'a', modifiers: ['Meta'] }],
['command+a', { key: 'a', modifiers: ['Meta'] }],
['CmdOrCtrl+a', { key: 'a', modifiers: [process.platform === 'darwin' ? 'Meta' : 'Ctrl'] }],
['ctrl++', { key: '+', modifiers: ['Ctrl'] }],
['ctrl+shift++', { key: '+', modifiers: ['Ctrl', 'Shift'] }],
['super+Left', { key: 'ArrowLeft', modifiers: ['Meta'] }],
['win+Right', { key: 'ArrowRight', modifiers: ['Meta'] }]
])('maps chord %s', (input, expected) => {
expect(parseKey(input)).toEqual(expected)
})
it('rejects unknown modifiers and key names', () => {
expect(() => parseKey('hyper+a')).toThrow(expect.objectContaining({ code: 'invalid_argument' }))
expect(() => parseKey('NotAKey')).toThrow(expect.objectContaining({ code: 'invalid_argument' }))
expect(() => parseKey('F25')).toThrow(expect.objectContaining({ code: 'invalid_argument' }))
})
})
-128
View File
@@ -1,128 +0,0 @@
import { RuntimeClientError } from './runtime-client-error'
export type KeyChord = {
key: string
modifiers: string[]
}
const MODIFIER_NAMES: Record<string, string> = {
ctrl: 'Ctrl',
control: 'Ctrl',
shift: 'Shift',
alt: 'Alt',
meta: 'Meta',
super: 'Meta',
cmd: 'Meta',
command: 'Meta',
win: 'Meta'
}
const PLATFORM_MODIFIER_NAMES: Record<string, () => string> = {
cmdorctrl: () => (process.platform === 'darwin' ? 'Meta' : 'Ctrl'),
commandorcontrol: () => (process.platform === 'darwin' ? 'Meta' : 'Ctrl')
}
const KEY_NAMES: Record<string, string> = {
Return: 'Enter',
Enter: 'Enter',
Escape: 'Escape',
Esc: 'Escape',
BackSpace: 'Backspace',
Backspace: 'Backspace',
Tab: 'Tab',
space: 'Space',
Space: 'Space',
Page_Up: 'PageUp',
Page_Down: 'PageDown',
Home: 'Home',
End: 'End',
Up: 'ArrowUp',
Down: 'ArrowDown',
Left: 'ArrowLeft',
Right: 'ArrowRight',
Delete: 'Delete',
Insert: 'Insert',
KP_Add: 'NumpadAdd',
KP_Subtract: 'NumpadSubtract',
KP_Multiply: 'NumpadMultiply',
KP_Divide: 'NumpadDivide',
KP_Enter: 'NumpadEnter'
}
export function parseKey(input: string): KeyChord {
const parts = splitKeyChord(input)
if (parts.length === 0) {
throw new RuntimeClientError('invalid_argument', 'key must not be empty')
}
const keyPart = parts.at(-1)!
const modifiers = parts.slice(0, -1).map(parseModifier)
return {
key: parseBaseKey(keyPart),
modifiers: dedupeModifiers(modifiers)
}
}
function splitKeyChord(input: string): string[] {
const trimmed = input.trim()
// Why: `+` is both the chord separator and a printable key users can press.
if (trimmed === '+') {
return ['+']
}
if (trimmed.endsWith('+')) {
return [
...trimmed
.slice(0, -1)
.split('+')
.map((part) => part.trim())
.filter(Boolean),
'+'
]
}
return input
.split('+')
.map((part) => part.trim())
.filter(Boolean)
}
function parseModifier(input: string): string {
const platformModifier = PLATFORM_MODIFIER_NAMES[input.toLowerCase()]
if (platformModifier) {
return platformModifier()
}
const modifier = MODIFIER_NAMES[input.toLowerCase()]
if (!modifier) {
throw new RuntimeClientError('invalid_argument', `unknown modifier '${input}'`)
}
return modifier
}
function parseBaseKey(input: string): string {
const mapped = KEY_NAMES[input]
if (mapped) {
return mapped
}
if (/^F([1-9]|1[0-9]|2[0-4])$/.test(input)) {
return input
}
const keypadDigit = input.match(/^KP_([0-9])$/)
if (keypadDigit) {
return `Numpad${keypadDigit[1]}`
}
if (isPrintableAscii(input)) {
return input
}
throw new RuntimeClientError('invalid_argument', `unknown key '${input}'`)
}
function isPrintableAscii(input: string): boolean {
if (input.length !== 1) {
return false
}
const code = input.charCodeAt(0)
return code >= 0x20 && code <= 0x7e
}
function dedupeModifiers(modifiers: string[]): string[] {
return [...new Set(modifiers)]
}
-120
View File
@@ -1,120 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
notificationCtorMock,
notificationIsSupportedMock,
notificationOnMock,
notificationRemoveListenerMock,
notificationShowMock,
shellOpenExternalMock,
isTrustedAccessibilityClientMock
} = vi.hoisted(() => {
const notificationOnMock = vi.fn()
const notificationRemoveListenerMock = vi.fn()
const notificationShowMock = vi.fn()
const notificationCtorMock = vi.fn(function () {
return {
on: notificationOnMock,
removeListener: notificationRemoveListenerMock,
show: notificationShowMock
}
})
return {
notificationCtorMock,
notificationIsSupportedMock: vi.fn(() => true),
notificationOnMock,
notificationRemoveListenerMock,
notificationShowMock,
shellOpenExternalMock: vi.fn(),
isTrustedAccessibilityClientMock: vi.fn(() => false)
}
})
vi.mock('electron', () => ({
Notification: Object.assign(notificationCtorMock, {
isSupported: notificationIsSupportedMock
}),
shell: {
openExternal: shellOpenExternalMock
},
systemPreferences: {
isTrustedAccessibilityClient: isTrustedAccessibilityClientMock
}
}))
import { notifyPermissionRequired } from './permissions'
describe('notifyPermissionRequired', () => {
const originalPlatform = process.platform
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllTimers()
notificationCtorMock.mockClear()
notificationIsSupportedMock.mockReset()
notificationIsSupportedMock.mockReturnValue(true)
notificationOnMock.mockClear()
notificationRemoveListenerMock.mockClear()
notificationShowMock.mockClear()
shellOpenExternalMock.mockClear()
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
})
afterEach(() => {
vi.useRealTimers()
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
})
function getNotificationEventHandler(eventName: string): (...args: unknown[]) => void {
const call = notificationOnMock.mock.calls.find((c: unknown[]) => c[0] === eventName)
if (!call) {
throw new Error(`Notification ${eventName} handler not registered`)
}
return call[1] as (...args: unknown[]) => void
}
it('clears the retained notification fallback timer when the notification closes', () => {
notifyPermissionRequired('Enable accessibility')
expect(notificationShowMock).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(1)
const closeHandler = getNotificationEventHandler('close')
closeHandler()
expect(vi.getTimerCount()).toBe(0)
expect(notificationRemoveListenerMock).toHaveBeenCalledWith('close', closeHandler)
expect(notificationRemoveListenerMock).toHaveBeenCalledWith('click', expect.any(Function))
})
it('opens macOS Accessibility settings on click after releasing notification state', () => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
notifyPermissionRequired('Enable accessibility')
getNotificationEventHandler('click')()
expect(shellOpenExternalMock).toHaveBeenCalledWith(
expect.stringContaining('Privacy_Accessibility')
)
expect(vi.getTimerCount()).toBe(0)
})
it('clears retained notification state when native delivery fails', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
notifyPermissionRequired('Enable accessibility')
expect(vi.getTimerCount()).toBe(1)
const failedHandler = getNotificationEventHandler('failed')
failedHandler({}, 'Application is not code signed')
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Accessibility permission notification failed')
)
expect(vi.getTimerCount()).toBe(0)
expect(notificationRemoveListenerMock).toHaveBeenCalledWith('failed', failedHandler)
} finally {
warn.mockRestore()
}
})
})
-58
View File
@@ -1,58 +0,0 @@
import { Notification, shell } from 'electron'
const ACCESSIBILITY_SETTINGS_URL =
'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility'
const activePermissionNotifications = new Set<Notification>()
/** Surface a notification through Orca's existing notification system (do not duplicate UI). */
export function notifyPermissionRequired(instructions: string): void {
if (!Notification.isSupported()) {
return
}
const notification = new Notification({
title: 'Accessibility permission required',
body: instructions
})
activePermissionNotifications.add(notification)
let releaseTimer: ReturnType<typeof setTimeout> | null = null
let released = false
function release(): void {
if (released) {
return
}
released = true
activePermissionNotifications.delete(notification)
notification.removeListener('close', release)
notification.removeListener('click', onClick)
notification.removeListener('failed', onFailed)
if (releaseTimer) {
clearTimeout(releaseTimer)
releaseTimer = null
}
}
function onClick(): void {
release()
if (process.platform === 'darwin') {
void shell.openExternal(ACCESSIBILITY_SETTINGS_URL)
}
}
function onFailed(_event: unknown, error?: string): void {
// Why: Electron 42 reports macOS UNNotification delivery failures here,
// most commonly when a local validation build is not code-signed.
console.warn(
`[computer] Accessibility permission notification failed${error ? `: ${error}` : '.'}`
)
release()
}
notification.on('close', release)
notification.on('click', onClick)
notification.on('failed', onFailed)
releaseTimer = setTimeout(release, 5 * 60 * 1000)
if (typeof releaseTimer.unref === 'function') {
releaseTimer.unref()
}
notification.show()
}
+5
View File
@@ -17,6 +17,7 @@ import {
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import {
readHooksJsonRemote,
writeHooksJsonRemote,
@@ -183,6 +184,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
}
export class CopilotHookService {
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript())
}
getStatus(): AgentHookInstallStatus {
const configPath = getConfigPath()
const scriptPath = getManagedScriptPath()
+5
View File
@@ -15,6 +15,7 @@ import {
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import {
readHooksJsonRemote,
writeHooksJsonRemote,
@@ -101,6 +102,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
}
export class CursorHookService {
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript())
}
getStatus(): AgentHookInstallStatus {
const configPath = getConfigPath()
const scriptPath = getManagedScriptPath()
-167
View File
@@ -1,167 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { encodeFrame, createFrameParser, FrameType, FRAME_HEADER_SIZE } from './binary-frame'
describe('encodeFrame', () => {
it('encodes a data frame with correct header', () => {
const payload = Buffer.from('hello')
const frame = encodeFrame(FrameType.Data, payload)
expect(frame.length).toBe(FRAME_HEADER_SIZE + payload.length)
expect(frame[0]).toBe(FrameType.Data)
expect(frame.readUInt32BE(1)).toBe(payload.length)
expect(frame.subarray(FRAME_HEADER_SIZE).toString()).toBe('hello')
})
it('encodes a resize frame', () => {
const payload = Buffer.from(JSON.stringify({ cols: 120, rows: 40 }))
const frame = encodeFrame(FrameType.Resize, payload)
expect(frame[0]).toBe(FrameType.Resize)
expect(frame.readUInt32BE(1)).toBe(payload.length)
})
it('encodes an exit frame with exit code', () => {
const payload = Buffer.from(JSON.stringify({ code: 0 }))
const frame = encodeFrame(FrameType.Exit, payload)
expect(frame[0]).toBe(FrameType.Exit)
const decoded = JSON.parse(frame.subarray(FRAME_HEADER_SIZE).toString())
expect(decoded.code).toBe(0)
})
it('encodes empty payload', () => {
const frame = encodeFrame(FrameType.Kill, Buffer.alloc(0))
expect(frame.length).toBe(FRAME_HEADER_SIZE)
expect(frame[0]).toBe(FrameType.Kill)
expect(frame.readUInt32BE(1)).toBe(0)
})
it('throws on payload exceeding max size', () => {
const oversized = Buffer.alloc(1024 * 1024 + 1)
expect(() => encodeFrame(FrameType.Data, oversized)).toThrow()
})
})
describe('createFrameParser', () => {
it('parses a single complete frame', () => {
const onFrame = vi.fn()
const parser = createFrameParser(onFrame)
const payload = Buffer.from('test data')
const frame = encodeFrame(FrameType.Data, payload)
parser.feed(frame)
expect(onFrame).toHaveBeenCalledOnce()
expect(onFrame.mock.calls[0][0]).toBe(FrameType.Data)
expect(onFrame.mock.calls[0][1].toString()).toBe('test data')
})
it('parses multiple frames in one chunk', () => {
const onFrame = vi.fn()
const parser = createFrameParser(onFrame)
const frame1 = encodeFrame(FrameType.Data, Buffer.from('one'))
const frame2 = encodeFrame(FrameType.Data, Buffer.from('two'))
parser.feed(Buffer.concat([frame1, frame2]))
expect(onFrame).toHaveBeenCalledTimes(2)
expect(onFrame.mock.calls[0][1].toString()).toBe('one')
expect(onFrame.mock.calls[1][1].toString()).toBe('two')
})
it('handles frame split across header boundary', () => {
const onFrame = vi.fn()
const parser = createFrameParser(onFrame)
const payload = Buffer.from('split test')
const frame = encodeFrame(FrameType.Data, payload)
// Split in the middle of the header (3 bytes, then the rest)
parser.feed(frame.subarray(0, 3))
expect(onFrame).not.toHaveBeenCalled()
parser.feed(frame.subarray(3))
expect(onFrame).toHaveBeenCalledOnce()
expect(onFrame.mock.calls[0][1].toString()).toBe('split test')
})
it('handles frame split in the middle of payload', () => {
const onFrame = vi.fn()
const parser = createFrameParser(onFrame)
const payload = Buffer.from('payload split')
const frame = encodeFrame(FrameType.Data, payload)
// Split after header + 3 bytes of payload
const splitPoint = FRAME_HEADER_SIZE + 3
parser.feed(frame.subarray(0, splitPoint))
expect(onFrame).not.toHaveBeenCalled()
parser.feed(frame.subarray(splitPoint))
expect(onFrame).toHaveBeenCalledOnce()
expect(onFrame.mock.calls[0][1].toString()).toBe('payload split')
})
it('handles byte-by-byte feeding', () => {
const onFrame = vi.fn()
const parser = createFrameParser(onFrame)
const frame = encodeFrame(FrameType.Exit, Buffer.from('{"code":42}'))
for (let i = 0; i < frame.length; i++) {
parser.feed(frame.subarray(i, i + 1))
}
expect(onFrame).toHaveBeenCalledOnce()
expect(onFrame.mock.calls[0][0]).toBe(FrameType.Exit)
expect(JSON.parse(onFrame.mock.calls[0][1].toString())).toEqual({ code: 42 })
})
it('handles zero-length payload frame', () => {
const onFrame = vi.fn()
const parser = createFrameParser(onFrame)
const frame = encodeFrame(FrameType.Kill, Buffer.alloc(0))
parser.feed(frame)
expect(onFrame).toHaveBeenCalledOnce()
expect(onFrame.mock.calls[0][0]).toBe(FrameType.Kill)
expect(onFrame.mock.calls[0][1].length).toBe(0)
})
it('parses different frame types correctly', () => {
const onFrame = vi.fn()
const parser = createFrameParser(onFrame)
const frames = [
encodeFrame(FrameType.Data, Buffer.from('data')),
encodeFrame(FrameType.Resize, Buffer.from('resize')),
encodeFrame(FrameType.Signal, Buffer.from('signal'))
]
parser.feed(Buffer.concat(frames))
expect(onFrame).toHaveBeenCalledTimes(3)
expect(onFrame.mock.calls[0][0]).toBe(FrameType.Data)
expect(onFrame.mock.calls[1][0]).toBe(FrameType.Resize)
expect(onFrame.mock.calls[2][0]).toBe(FrameType.Signal)
})
it('resets buffer state', () => {
const onFrame = vi.fn()
const parser = createFrameParser(onFrame)
// Feed partial frame then reset
const frame = encodeFrame(FrameType.Data, Buffer.from('lost'))
parser.feed(frame.subarray(0, 3))
parser.reset()
// Feed a new complete frame
const frame2 = encodeFrame(FrameType.Data, Buffer.from('fresh'))
parser.feed(frame2)
expect(onFrame).toHaveBeenCalledOnce()
expect(onFrame.mock.calls[0][1].toString()).toBe('fresh')
})
})
-56
View File
@@ -1,56 +0,0 @@
export { FrameType } from './types'
import type { FrameType } from './types'
import { FRAME_HEADER_SIZE, FRAME_MAX_PAYLOAD } from './types'
export { FRAME_HEADER_SIZE }
export function encodeFrame(type: FrameType, payload: Buffer): Buffer {
if (payload.length > FRAME_MAX_PAYLOAD) {
throw new Error(`Frame payload ${payload.length} exceeds max ${FRAME_MAX_PAYLOAD}`)
}
const frame = Buffer.allocUnsafe(FRAME_HEADER_SIZE + payload.length)
frame[0] = type
frame.writeUInt32BE(payload.length, 1)
payload.copy(frame, FRAME_HEADER_SIZE)
return frame
}
export type FrameParser = {
feed(chunk: Buffer): void
reset(): void
}
export function createFrameParser(
onFrame: (type: FrameType, payload: Buffer) => void
): FrameParser {
let buffer: Buffer = Buffer.alloc(0)
function parse(): void {
while (buffer.length >= FRAME_HEADER_SIZE) {
const payloadLength = buffer.readUInt32BE(1)
const totalLength = FRAME_HEADER_SIZE + payloadLength
if (buffer.length < totalLength) {
break
}
const type = buffer[0] as FrameType
const payload = buffer.subarray(FRAME_HEADER_SIZE, totalLength)
buffer = buffer.subarray(totalLength)
onFrame(type, payload)
}
}
return {
feed(chunk: Buffer): void {
buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk])
parse()
},
reset(): void {
buffer = Buffer.alloc(0)
}
}
}
@@ -1321,6 +1321,18 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
source: 'headless'
})
})
it('exposes live state separately from the visible frame', async () => {
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
lastSubprocess._simulateData('\x1b[?1049h\x1b[?1004h\x1b[?25lframe')
const snapshot = await adapter.getBufferSnapshot(id)
expect(snapshot?.frameRestoreAnsi).toContain('\x1b[?1004h')
expect(snapshot?.frameRestoreAnsi).toContain('\x1b[?25l')
expect(snapshot?.frameRestoreAnsi).not.toContain('frame')
expect(snapshot?.data).toContain('frame')
})
})
describe('shutdown', () => {
@@ -1506,6 +1518,19 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(result.snapshot).toContain('prompt$')
})
it('publishes the alt-frame payload as explicit strings', async () => {
const sessionId = 'alt-frame-boundary'
await adapter.spawn({ cols: 80, rows: 24, sessionId })
lastSubprocess._simulateData('\x1b[?1049h\x1b[HSTATIC-ALT-FRAME')
await new Promise((r) => setTimeout(r, 50))
const result = await adapter.spawn({ cols: 80, rows: 24, sessionId })
expect(result.snapshotPrefixAnsi).toContain('\x1b[?1049h')
expect(result.snapshotFrameAnsi).toContain('STATIC-ALT-FRAME')
expect(result.snapshot).toBe([result.snapshotPrefixAnsi, result.snapshotFrameAnsi].join(''))
expect(result.snapshotFrameRestoreAnsi).not.toContain('STATIC-ALT-FRAME')
})
it('returns the preserved sequence from attach-only adoption', async () => {
const sessionId = 'attach-sequence-handoff'
await adapter.spawn({ cols: 80, rows: 24, sessionId })
+12 -4
View File
@@ -831,10 +831,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
const isAltScreen = result.snapshot.modes.alternateScreen
const snapshotPayload =
result.snapshot.scrollbackAnsi +
result.snapshot.rehydrateSequences +
result.snapshot.snapshotAnsi
const snapshotPrefix = result.snapshot.scrollbackAnsi + result.snapshot.rehydrateSequences
const snapshotFrame = result.snapshot.snapshotAnsi
const snapshotPayload = snapshotPrefix + snapshotFrame
// Why kitty flags ride beside the payload, not inside it: the snapshot reaches renderer xterms where POST_REPLAY_REATTACH_RESET's kitty reset must win (terminal-query-authority.md §kitty).
const kittyKeyboardFlags = result.snapshot.modes.kittyKeyboardFlags
return {
@@ -847,6 +846,14 @@ export class DaemonPtyAdapter implements IPtyProvider {
snapshot: snapshotPayload,
snapshotCols: result.snapshot.cols,
snapshotRows: result.snapshot.rows,
// Why only for an alt frame: normal history remains safe to replay at its capture grid.
...(isAltScreen && snapshotFrame && result.snapshot.frameRestoreAnsi
? {
snapshotPrefixAnsi: snapshotPrefix,
snapshotFrameAnsi: snapshotFrame,
snapshotFrameRestoreAnsi: result.snapshot.frameRestoreAnsi
}
: {}),
...(providerSequence ? { providerSequence } : {}),
...(typeof kittyKeyboardFlags === 'number' && kittyKeyboardFlags > 0
? { snapshotKittyKeyboardFlags: kittyKeyboardFlags }
@@ -1216,6 +1223,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
return {
data: snapshot.rehydrateSequences + snapshot.snapshotAnsi,
frameRestoreAnsi: snapshot.frameRestoreAnsi,
scrollbackAnsi: snapshot.scrollbackAnsi,
cols: snapshot.cols,
rows: snapshot.rows,
-198
View File
@@ -1,198 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { mkdtempSync, rmSync } from 'node:fs'
import { DaemonPtyProvider } from './daemon-pty-provider'
import { DaemonServer } from './daemon-server'
import type { SubprocessHandle } from './session'
import { getDaemonSocketPath } from './daemon-spawner'
function createTestDir(): string {
return mkdtempSync(join(tmpdir(), 'daemon-provider-test-'))
}
function createMockSubprocess(): SubprocessHandle & {
_simulateData: (data: string) => void
_simulateExit: (code: number) => void
} {
let onDataCb: ((data: string) => void) | null = null
let onExitCb: ((code: number) => void) | null = null
return {
pid: 77777,
getForegroundProcess: vi.fn(() => null),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)),
forceKill: vi.fn(() => setTimeout(() => onExitCb?.(137), 5)),
signal: vi.fn(),
onData(cb) {
onDataCb = cb
},
onExit(cb) {
onExitCb = cb
},
dispose: vi.fn(),
_simulateData(data: string) {
onDataCb?.(data)
},
_simulateExit(code: number) {
onExitCb?.(code)
}
}
}
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
const start = Date.now()
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error('waitFor timed out')
}
await new Promise((r) => setTimeout(r, 10))
}
}
describe('DaemonPtyProvider', () => {
let dir: string
let socketPath: string
let tokenPath: string
let server: DaemonServer
let provider: DaemonPtyProvider
let lastSubprocess: ReturnType<typeof createMockSubprocess>
beforeEach(async () => {
dir = createTestDir()
socketPath = getDaemonSocketPath(dir)
tokenPath = join(dir, 'test.token')
server = new DaemonServer({
socketPath,
tokenPath,
spawnSubprocess: () => {
lastSubprocess = createMockSubprocess()
return lastSubprocess
}
})
await server.start()
provider = new DaemonPtyProvider({ socketPath, tokenPath })
})
afterEach(async () => {
await provider?.cleanup()
await server?.shutdown()
rmSync(dir, { recursive: true, force: true })
})
describe('spawn', () => {
it('creates a session in the daemon and returns an id', async () => {
const result = await provider.spawn({
cols: 80,
rows: 24,
sessionId: 'test-session'
})
expect(result.id).toBe('test-session')
})
it('spawns with cwd and env', async () => {
const result = await provider.spawn({
cols: 80,
rows: 24,
cwd: '/home/user',
env: { MY_VAR: 'hello' },
sessionId: 'session-with-env'
})
expect(result.id).toBe('session-with-env')
})
})
describe('write', () => {
it('sends data to the daemon session', async () => {
await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' })
// Should not throw
provider.write('s1', 'ls\n')
// Give the fire-and-forget notify time to arrive
await new Promise((r) => setTimeout(r, 50))
expect(lastSubprocess.write).toHaveBeenCalledWith('ls\n')
})
})
describe('resize', () => {
it('resizes the daemon session', async () => {
await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' })
provider.resize('s1', 120, 40)
await new Promise((r) => setTimeout(r, 50))
expect(lastSubprocess.resize).toHaveBeenCalledWith(120, 40)
})
})
describe('shutdown (kill session)', () => {
it('kills the session in the daemon', async () => {
await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' })
await provider.shutdown('s1', { immediate: false })
expect(lastSubprocess.kill).toHaveBeenCalled()
})
})
describe('data events', () => {
it('delivers data events from the daemon to onData listeners', async () => {
const dataPayloads: { id: string; data: string }[] = []
provider.onData((payload) => dataPayloads.push(payload))
await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' })
// Simulate PTY output from subprocess
lastSubprocess._simulateData('hello from shell')
await waitFor(() => dataPayloads.length > 0)
expect(dataPayloads[0]).toEqual({ id: 's1', data: 'hello from shell' })
})
})
describe('exit events', () => {
it('delivers exit events from the daemon to onExit listeners', async () => {
const exits: { id: string; code: number }[] = []
provider.onExit((payload) => exits.push(payload))
await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' })
lastSubprocess._simulateExit(42)
await waitFor(() => exits.length > 0)
expect(exits[0]).toEqual({ id: 's1', code: 42, incarnationId: expect.any(String) })
})
})
describe('cleanup', () => {
it('disconnects from daemon without killing sessions', async () => {
await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' })
await provider.cleanup()
// Session should still be alive in the daemon — verify by connecting a new provider
const provider2 = new DaemonPtyProvider({ socketPath, tokenPath })
const result = await provider2.spawn({ cols: 80, rows: 24, sessionId: 's1' })
// Should reattach (not create new) since the session is alive
// The daemon's createOrAttach returns isNew=false for existing sessions
expect(result.id).toBe('s1')
await provider2.cleanup()
})
})
describe('multiple sessions', () => {
it('handles multiple concurrent sessions', async () => {
const r1 = await provider.spawn({ cols: 80, rows: 24, sessionId: 'a' })
const r2 = await provider.spawn({ cols: 80, rows: 24, sessionId: 'b' })
const r3 = await provider.spawn({ cols: 80, rows: 24, sessionId: 'c' })
expect(r1.id).toBe('a')
expect(r2.id).toBe('b')
expect(r3.id).toBe('c')
})
})
})
-141
View File
@@ -1,141 +0,0 @@
import { DaemonClient } from './client'
import type { DaemonEvent, CreateOrAttachResult } from './types'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
export type DaemonPtyProviderOptions = {
socketPath: string
tokenPath: string
}
export type DaemonSpawnOptions = {
cols: number
rows: number
sessionId: string
cwd?: string
env?: Record<string, string>
envToDelete?: string[]
command?: string
}
export type DaemonSpawnResult = {
id: string
incarnationId?: PtyIncarnationId
isNew: boolean
pid: number | null
}
export class DaemonPtyProvider {
private client: DaemonClient
private dataListeners: ((payload: { id: string; data: string }) => void)[] = []
private exitListeners: ((payload: {
id: string
code: number
incarnationId?: PtyIncarnationId
}) => void)[] = []
private removeEventListener: (() => void) | null = null
constructor(opts: DaemonPtyProviderOptions) {
this.client = new DaemonClient({
socketPath: opts.socketPath,
tokenPath: opts.tokenPath
})
}
async spawn(opts: DaemonSpawnOptions): Promise<DaemonSpawnResult> {
await this.client.ensureConnected()
this.setupEventRouting()
const result = await this.client.request<CreateOrAttachResult>('createOrAttach', {
sessionId: opts.sessionId,
cols: opts.cols,
rows: opts.rows,
cwd: opts.cwd,
env: opts.env,
envToDelete: opts.envToDelete,
command: opts.command
})
return {
id: opts.sessionId,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
isNew: result.isNew,
pid: result.pid
}
}
write(id: string, data: string): void {
this.client.notify('write', { sessionId: id, data })
}
resize(id: string, cols: number, rows: number): void {
this.client.notify('resize', { sessionId: id, cols, rows })
}
async shutdown(
id: string,
opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number }
): Promise<void> {
// Why: convert the absolute teardown deadline to a relative timeout only here,
// at the RPC leaf; undefined keeps the DaemonClient 30s default.
await this.client.request(
'kill',
{ sessionId: id, immediate: opts.immediate ?? false },
opts.deadlineMs !== undefined ? Math.max(1, opts.deadlineMs - Date.now()) : undefined
)
}
onData(callback: (payload: { id: string; data: string }) => void): () => void {
this.dataListeners.push(callback)
return () => {
const idx = this.dataListeners.indexOf(callback)
if (idx !== -1) {
this.dataListeners.splice(idx, 1)
}
}
}
onExit(
callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void
): () => void {
this.exitListeners.push(callback)
return () => {
const idx = this.exitListeners.indexOf(callback)
if (idx !== -1) {
this.exitListeners.splice(idx, 1)
}
}
}
async cleanup(): Promise<void> {
this.removeEventListener?.()
this.removeEventListener = null
this.client.disconnect()
}
private setupEventRouting(): void {
if (this.removeEventListener) {
return
}
this.removeEventListener = this.client.onEvent((raw) => {
const event = raw as DaemonEvent
if (event.type !== 'event') {
return
}
if (event.event === 'data') {
for (const listener of this.dataListeners) {
listener({ id: event.sessionId, data: event.payload.data })
}
} else if (event.event === 'exit') {
for (const listener of this.exitListeners) {
listener({
id: event.sessionId,
code: event.payload.code,
...(event.payload.incarnationId ? { incarnationId: event.payload.incarnationId } : {})
})
}
}
})
}
}
+49
View File
@@ -575,6 +575,55 @@ describe('HeadlessEmulator', () => {
})
describe('rehydration sequences', () => {
it('separates complete live state from an alternate-screen frame', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b[?1049h\x1b[?1004h\x1b[?25l\x1b[5;10H\x1b7\x1b[31;44;1mframe')
const snapshot = emulator.getSnapshot()
expect(snapshot.frameRestoreAnsi).toContain('\x1b[?1049h')
expect(snapshot.frameRestoreAnsi).toContain('\x1b[?1004h')
expect(snapshot.frameRestoreAnsi).toContain('\x1b[?25l')
expect(snapshot.frameRestoreAnsi).toContain('\x1b7')
expect(snapshot.frameRestoreAnsi).toContain('\x1b[31;44;1m')
expect(snapshot.frameRestoreAnsi).not.toContain('frame')
const restored = new HeadlessEmulator({ cols: 80, rows: 24 })
await restored.write(snapshot.frameRestoreAnsi ?? '')
expect(restored.isAlternateScreen).toBe(true)
await restored.write('X')
expect(restored.getSnapshot().snapshotAnsi).toContain('\x1b[31;44;1mX')
restored.dispose()
})
it('restores current and saved cursor rows relative to origin-mode margins', async () => {
emulator = new HeadlessEmulator({ cols: 20, rows: 10 })
await emulator.write('\x1b[?1049h\x1b[3;8r\x1b[?6h\x1b[2;7H\x1b7\x1b[4;5H')
const snapshot = emulator.getSnapshot()
const restored = new HeadlessEmulator({ cols: 20, rows: 10 })
await restored.write(snapshot.frameRestoreAnsi ?? '')
await restored.write('C\x1b8S')
expect(restored.getVisibleLines()[5]?.[4]).toBe('C')
expect(restored.getVisibleLines()[3]?.[6]).toBe('S')
restored.dispose()
})
it('restores a saved cursor from before the current origin-mode margins', async () => {
emulator = new HeadlessEmulator({ cols: 20, rows: 10 })
await emulator.write('\x1b[?1049h\x1b[1;3H\x1b7\x1b[3;8r\x1b[?6h\x1b[4;5H')
const snapshot = emulator.getSnapshot()
const restored = new HeadlessEmulator({ cols: 20, rows: 10 })
await restored.write(snapshot.frameRestoreAnsi ?? '')
await restored.write('C\x1b8S')
expect(restored.getVisibleLines()[5]?.[4]).toBe('C')
expect(restored.getVisibleLines()[0]?.[2]).toBe('S')
restored.dispose()
})
it('generates rehydration for non-default modes', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b[?2004h')
+2 -4
View File
@@ -13,6 +13,7 @@ import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges'
import { buildRehydrateSequences } from './terminal-mode-rehydrate-sequences'
import { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror'
import { TerminalOscCwdTitleScanner } from './terminal-osc-cwd-title-scanner'
import { buildFrameRestoreSnapshotFields } from './terminal-frame-restore-sequences'
import { splitTerminalSnapshotAnsi } from './terminal-snapshot-ansi-buffers'
import {
installTerminalViewAttributeResponder,
@@ -255,6 +256,7 @@ export class HeadlessEmulator {
this.restoredOscLinks
),
rehydrateSequences: buildRehydrateSequences(modes),
...buildFrameRestoreSnapshotFields(this.serializer, this.terminal, modes),
cwd: this.oscText.cwd,
modes,
cols: this.terminal.cols,
@@ -266,10 +268,6 @@ export class HeadlessEmulator {
? { pendingEscapeTailAnsi: this.partialEscapeTail }
: {})
}
if (this.partialEscapeTail.length > 0) {
// Why a separate field: consumers write their own reset sequences after the body, and any ESC after a dangling partial would abort it.
snapshot.pendingEscapeTailAnsi = this.partialEscapeTail
}
return snapshot
}
-444
View File
@@ -1,444 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { createProductionLauncher } from './production-launcher'
import { startDaemon, type DaemonHandle } from './daemon-main'
import { DaemonClient } from './client'
import type { SubprocessHandle } from './session'
import { getDaemonSocketPath } from './daemon-spawner'
const { forkMock } = vi.hoisted(() => ({
forkMock: vi.fn()
}))
vi.mock('child_process', async () => {
const actual = await vi.importActual<Record<string, unknown>>('child_process')
return { ...actual, fork: forkMock }
})
function createTestDir(): string {
return mkdtempSync(join(tmpdir(), 'prod-launcher-test-'))
}
function createMockSubprocess(): SubprocessHandle {
let onExitCb: ((code: number) => void) | null = null
return {
pid: 44444,
getForegroundProcess: vi.fn(() => null),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)),
forceKill: vi.fn(),
signal: vi.fn(),
onData(_cb: (data: string) => void) {},
onExit(cb: (code: number) => void) {
onExitCb = cb
},
dispose: vi.fn()
}
}
describe('createProductionLauncher', () => {
let dir: string
let handles: DaemonHandle[]
beforeEach(() => {
dir = createTestDir()
handles = []
})
afterEach(async () => {
for (const h of handles) {
await h.shutdown().catch(() => {})
}
forkMock.mockReset()
rmSync(dir, { recursive: true, force: true })
})
it('returns a launcher function', () => {
const launcher = createProductionLauncher({
getDaemonEntryPath: () => '/fake/path.js',
getAppVersion: () => '1.2.3'
})
expect(typeof launcher).toBe('function')
})
it('rejects either ownership argument without its pair before forking', async () => {
const launcher = createProductionLauncher({
getDaemonEntryPath: () => '/fake/path.js',
getAppVersion: () => '1.2.3'
})
await expect(
launcher(socketPathFor(dir), tokenPathFor(dir), join(dir, 'daemon.pid'))
).rejects.toThrow('provided together')
await expect(
launcher(socketPathFor(dir), tokenPathFor(dir), undefined, 'launch-a')
).rejects.toThrow('provided together')
expect(forkMock).not.toHaveBeenCalled()
})
it('can be used with DaemonSpawner (in-process fallback)', async () => {
// Use in-process launcher for testing (same as DaemonSpawner tests)
const launcher = async (socketPath: string, tokenPath: string) => {
const handle = await startDaemon({
socketPath,
tokenPath,
spawnSubprocess: () => createMockSubprocess()
})
handles.push(handle)
return { shutdown: () => handle.shutdown() }
}
const socketPath = getDaemonSocketPath(dir)
const tokenPath = join(dir, 'test.token')
const handle = await launcher(socketPath, tokenPath)
const client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
expect(client.isConnected()).toBe(true)
client.disconnect()
await handle.shutdown()
handles.pop()
})
it('removes startup child listeners after readiness', async () => {
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
message: [],
error: [],
exit: []
}
const child = {
pid: 12345,
killed: false,
on: vi.fn((event: string, cb: (arg?: unknown) => void) => {
handlers[event]?.push(cb)
return child
}),
off: vi.fn((event: string, cb: (arg?: unknown) => void) => {
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
return child
}),
kill: vi.fn(),
disconnect: vi.fn(),
unref: vi.fn()
}
forkMock.mockReturnValueOnce(child)
const launcher = createProductionLauncher({
getDaemonEntryPath: () => join(dir, 'daemon-entry.js'),
getAppVersion: () => '1.2.3'
})
const pidPath = join(dir, 'daemon.pid')
const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-a')
handlers.message[0]?.({
type: 'ready',
startedAtMs: 123_456,
linuxStartTicks: '4242',
bootId: 'boot-a'
})
const handle = await launch
expect(handle.shutdown).toEqual(expect.any(Function))
expect(handlers.message).toHaveLength(0)
expect(handlers.error).toHaveLength(0)
expect(handlers.exit).toHaveLength(0)
expect(child.disconnect).toHaveBeenCalled()
expect(child.unref).toHaveBeenCalled()
expect(forkMock).toHaveBeenCalledWith(
join(dir, 'daemon-entry.js'),
expect.arrayContaining([
'--pid-record',
pidPath,
'--launch-nonce',
'launch-a',
'--entry-path',
join(dir, 'daemon-entry.js'),
'--app-version',
'1.2.3'
]),
expect.objectContaining({ stdio: ['ignore', 'ignore', 'ignore', 'ipc'] })
)
})
it('resolves shutdown only after observing child exit', async () => {
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
message: [],
error: [],
exit: []
}
const child = {
pid: 12345,
connected: true,
exitCode: null as number | null,
signalCode: null as NodeJS.Signals | null,
on: vi.fn((event: string, callback: (arg?: unknown) => void) => {
handlers[event]?.push(callback)
return child
}),
once: vi.fn((event: string, callback: (arg?: unknown) => void) => {
handlers[event]?.push(callback)
return child
}),
off: vi.fn((event: string, callback: (arg?: unknown) => void) => {
handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? []
return child
}),
kill: vi.fn((signal: NodeJS.Signals) => {
if (signal === 'SIGTERM') {
queueMicrotask(() => {
child.exitCode = 0
for (const callback of handlers.exit.slice()) {
callback(0)
}
})
}
return true
}),
disconnect: vi.fn(() => {
child.connected = false
}),
unref: vi.fn()
}
forkMock.mockReturnValueOnce(child)
const launcher = createProductionLauncher({
getDaemonEntryPath: () => join(dir, 'daemon-entry.js'),
getAppVersion: () => '1.2.3'
})
const pidPath = join(dir, 'daemon.pid')
writeFileSync(pidPath, JSON.stringify({ pid: 12345, launchNonce: 'launch-shutdown' }))
const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-shutdown')
handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 })
const handle = await launch
await expect(handle.shutdown()).resolves.toBeUndefined()
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
expect(child.exitCode).toBe(0)
expect(existsSync(pidPath)).toBe(false)
})
it('rejects shutdown and releases child handles when SIGKILL never produces exit', async () => {
vi.useFakeTimers()
try {
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
message: [],
error: [],
exit: []
}
const child = {
pid: 12345,
killed: false,
connected: false,
exitCode: null,
signalCode: null,
on: vi.fn((event: string, cb: (arg?: unknown) => void) => {
handlers[event]?.push(cb)
return child
}),
once: vi.fn((event: string, cb: (arg?: unknown) => void) => {
handlers[event]?.push(cb)
return child
}),
off: vi.fn((event: string, cb: (arg?: unknown) => void) => {
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
return child
}),
kill: vi.fn(() => true),
disconnect: vi.fn(() => {
child.connected = false
}),
unref: vi.fn()
}
forkMock.mockReturnValueOnce(child)
const launcher = createProductionLauncher({
getDaemonEntryPath: () => join(dir, 'daemon-entry.js'),
getAppVersion: () => '1.2.3'
})
const launch = launcher(socketPathFor(dir), tokenPathFor(dir))
handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 })
const handle = await launch
const shutdown = expect(handle.shutdown()).rejects.toThrow(
'Daemon did not exit after SIGKILL'
)
expect(handlers.exit).toHaveLength(1)
await vi.advanceTimersByTimeAsync(6000)
await shutdown
expect(child.kill).toHaveBeenNthCalledWith(1, 'SIGTERM')
expect(child.kill).toHaveBeenNthCalledWith(2, 'SIGKILL')
expect(handlers.exit).toHaveLength(0)
expect(child.unref).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
it('preserves readiness and signaling failures while releasing startup IPC', async () => {
vi.useFakeTimers()
try {
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
message: [],
error: [],
exit: []
}
const signalError = Object.assign(new Error('permission denied'), { code: 'EACCES' })
const child = {
pid: 12345,
connected: true,
exitCode: null,
signalCode: null,
on: vi.fn((event: string, cb: (arg?: unknown) => void) => {
handlers[event]?.push(cb)
return child
}),
once: vi.fn((event: string, cb: (arg?: unknown) => void) => {
handlers[event]?.push(cb)
return child
}),
off: vi.fn((event: string, cb: (arg?: unknown) => void) => {
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
return child
}),
kill: vi.fn(() => {
throw signalError
}),
disconnect: vi.fn(() => {
child.connected = false
}),
unref: vi.fn()
}
forkMock.mockReturnValueOnce(child)
const launcher = createProductionLauncher({
getDaemonEntryPath: () => join(dir, 'daemon-entry.js'),
getAppVersion: () => '1.2.3'
})
const launch = launcher(socketPathFor(dir), tokenPathFor(dir))
handlers.message[0]?.({ type: 'ready' })
const error = await launch.catch((caught: unknown) => caught)
expect(error).toBeInstanceOf(AggregateError)
expect((error as AggregateError).errors).toEqual([
expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }),
signalError
])
expect(child.disconnect).toHaveBeenCalledOnce()
expect(child.unref).toHaveBeenCalledOnce()
expect(handlers.message).toHaveLength(0)
expect(handlers.error).toHaveLength(0)
expect(handlers.exit).toHaveLength(0)
} finally {
vi.useRealTimers()
}
})
it('preserves exact PID ownership when child exit cannot be confirmed', async () => {
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
message: [],
error: [],
exit: []
}
const signalError = Object.assign(new Error('signal blocked'), { code: 'EPERM' })
const child = {
pid: 12345,
connected: true,
exitCode: null,
signalCode: null,
on: vi.fn((event: string, cb: (arg?: unknown) => void) => {
handlers[event]?.push(cb)
return child
}),
once: vi.fn((event: string, cb: (arg?: unknown) => void) => {
handlers[event]?.push(cb)
return child
}),
off: vi.fn((event: string, cb: (arg?: unknown) => void) => {
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
return child
}),
kill: vi.fn(() => {
throw signalError
}),
disconnect: vi.fn(() => {
child.connected = false
}),
unref: vi.fn()
}
forkMock.mockReturnValueOnce(child)
const launcher = createProductionLauncher({
getDaemonEntryPath: () => join(dir, 'daemon-entry.js'),
getAppVersion: () => '1.2.3'
})
const pidPath = join(dir, 'daemon.pid')
writeFileSync(pidPath, JSON.stringify({ pid: 12345, launchNonce: 'launch-b' }))
const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-b')
handlers.message[0]?.({ type: 'ready' })
const error = await launch.catch((caught: unknown) => caught)
expect(error).toBeInstanceOf(AggregateError)
expect((error as AggregateError).errors).toEqual([
expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }),
signalError
])
expect(child.disconnect).toHaveBeenCalledOnce()
expect(child.unref).toHaveBeenCalledOnce()
expect(existsSync(pidPath)).toBe(true)
})
it('removes exact child-side PID ownership after a pre-ready exit', async () => {
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
message: [],
error: [],
exit: []
}
const child = {
pid: 12345,
connected: true,
exitCode: null as number | null,
signalCode: null as NodeJS.Signals | null,
on: vi.fn((event: string, cb: (arg?: unknown) => void) => {
handlers[event]?.push(cb)
return child
}),
off: vi.fn((event: string, cb: (arg?: unknown) => void) => {
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
return child
}),
disconnect: vi.fn(() => {
child.connected = false
}),
unref: vi.fn()
}
forkMock.mockReturnValueOnce(child)
const pidPath = join(dir, 'daemon.pid')
writeFileSync(pidPath, JSON.stringify({ pid: 12345, launchNonce: 'launch-c' }))
const launcher = createProductionLauncher({
getDaemonEntryPath: () => join(dir, 'daemon-entry.js'),
getAppVersion: () => '1.2.3'
})
const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-c')
child.exitCode = 1
handlers.exit[0]?.(1)
await expect(launch).rejects.toThrow('Daemon process exited prematurely with code 1')
expect(existsSync(pidPath)).toBe(false)
})
})
function socketPathFor(dir: string): string {
return join(dir, 'test.sock')
}
function tokenPathFor(dir: string): string {
return join(dir, 'test.token')
}
-242
View File
@@ -1,242 +0,0 @@
import { fork, type ChildProcess } from 'node:child_process'
import {
unlinkOwnedDaemonPidFile,
type DaemonLauncher,
type DaemonProcessHandle
} from './daemon-spawner'
import { parseDaemonReadyIdentity, type DaemonReadyIdentity } from './daemon-ready-identity'
const READY_TIMEOUT_MS = 10_000
export type ProductionLauncherOptions = {
getDaemonEntryPath: () => string
getAppVersion: () => string
}
export function createProductionLauncher(opts: ProductionLauncherOptions): DaemonLauncher {
return async (
socketPath: string,
tokenPath: string,
pidPath?: string,
launchNonce?: string
): Promise<DaemonProcessHandle> => {
if ((pidPath === undefined) !== (launchNonce === undefined)) {
// Why: partial ownership metadata would launch a v24 daemon that cannot
// prove which PID record it may remove during self-retirement.
throw new Error('Daemon PID path and launch nonce must be provided together')
}
const entryPath = opts.getDaemonEntryPath()
const appVersion = opts.getAppVersion()
const child = fork(
entryPath,
[
'--socket',
socketPath,
'--token',
tokenPath,
...(pidPath && launchNonce
? [
'--pid-record',
pidPath,
'--launch-nonce',
launchNonce,
'--entry-path',
entryPath,
'--app-version',
appVersion
]
: [])
],
{
// Why: detached daemon output is not consumed; ignored streams cannot
// keep Electron alive after the child and IPC channel are unreferenced.
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
detached: true,
env: { ...process.env },
...(process.platform === 'win32' ? { windowsHide: true } : {})
}
)
try {
await waitForReady(child)
} catch (error) {
return rejectAfterChildCleanup(child, error, pidPath, launchNonce)
}
if (!Number.isSafeInteger(child.pid) || (child.pid as number) <= 0) {
return rejectAfterChildCleanup(
child,
new Error('Daemon readiness identity is incomplete'),
pidPath,
launchNonce
)
}
// Unref so the Electron process can exit without waiting for the daemon
child.unref()
child.disconnect()
return {
shutdown: async () => {
await shutdownChild(child)
unlinkLaunchedDaemonPidFile(child, pidPath, launchNonce)
}
}
}
}
function waitForReady(child: ChildProcess): Promise<DaemonReadyIdentity> {
return new Promise((resolve, reject) => {
let timeout: ReturnType<typeof setTimeout> | undefined
let settled = false
function cleanupStartupListeners(): void {
if (timeout) {
clearTimeout(timeout)
}
child.off('message', onMessage)
child.off('error', onError)
child.off('exit', onExit)
}
function fail(error: Error): void {
if (settled) {
return
}
settled = true
cleanupStartupListeners()
reject(error)
}
function onMessage(msg: unknown): void {
if (msg && typeof msg === 'object' && (msg as Record<string, unknown>).type === 'ready') {
if (settled) {
return
}
const readyIdentity = parseDaemonReadyIdentity(msg)
if (!readyIdentity) {
fail(new Error('Daemon readiness identity is incomplete'))
return
}
settled = true
// Why: the daemon is detached after readiness, so startup listeners
// must not keep the child process closure alive for the daemon lifetime.
cleanupStartupListeners()
resolve(readyIdentity)
}
}
function onError(err: Error): void {
fail(new Error(`Daemon process error: ${err.message}`))
}
function onExit(code: number | null): void {
fail(new Error(`Daemon process exited prematurely with code ${code}`))
}
timeout = setTimeout(() => {
fail(new Error('Daemon failed to signal readiness within timeout'))
}, READY_TIMEOUT_MS)
child.on('message', onMessage)
child.on('error', onError)
child.on('exit', onExit)
})
}
async function shutdownChild(child: ChildProcess): Promise<void> {
try {
if (
(child.exitCode !== null && child.exitCode !== undefined) ||
(child.signalCode !== null && child.signalCode !== undefined)
) {
return
}
await new Promise<void>((resolve, reject) => {
let settled = false
let timeout: ReturnType<typeof setTimeout>
let forceTimeout: ReturnType<typeof setTimeout> | undefined
function finish(error?: unknown): void {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
if (forceTimeout) {
clearTimeout(forceTimeout)
}
child.off('exit', onExit)
if (error) {
reject(error)
} else {
resolve()
}
}
function onExit(): void {
finish()
}
timeout = setTimeout(() => {
try {
if (child.kill('SIGKILL') === false) {
finish(new Error('Failed to deliver SIGKILL to daemon'))
return
}
} catch (error) {
finish(isNoSuchProcessError(error) ? undefined : error)
return
}
// Why: signal delivery is not process exit; keep the listener for one
// bounded interval before releasing launcher-owned handles.
if (!settled) {
forceTimeout = setTimeout(
() => finish(new Error('Daemon did not exit after SIGKILL')),
1000
)
}
}, 5000)
child.once('exit', onExit)
try {
if (child.kill('SIGTERM') === false) {
finish(new Error('Failed to deliver SIGTERM to daemon'))
}
} catch (error) {
finish(isNoSuchProcessError(error) ? undefined : error)
}
})
} finally {
if (child.connected) {
child.disconnect()
}
child.unref()
}
}
async function rejectAfterChildCleanup(
child: ChildProcess,
launchError: unknown,
pidPath?: string,
launchNonce?: string
): Promise<never> {
try {
await shutdownChild(child)
} catch (cleanupError) {
throw new AggregateError(
[launchError, cleanupError],
'Daemon launch and child cleanup both failed'
)
}
unlinkLaunchedDaemonPidFile(child, pidPath, launchNonce)
throw launchError
}
function unlinkLaunchedDaemonPidFile(
child: ChildProcess,
pidPath?: string,
launchNonce?: string
): void {
if (pidPath && launchNonce && Number.isSafeInteger(child.pid) && (child.pid as number) > 0) {
unlinkOwnedDaemonPidFile(pidPath, child.pid as number, launchNonce)
}
}
function isNoSuchProcessError(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH'
}
@@ -0,0 +1,97 @@
import type { Terminal } from '@xterm/headless'
import type { SerializeAddon } from '@xterm/addon-serialize'
import {
buildAbsoluteCursorRestoreSequence,
readSavedCursorRegister
} from '../../shared/terminal-serialize-absolute-cursor'
import type { SavedCursorRegister } from '../../shared/terminal-serialize-absolute-cursor'
import type { TerminalModes } from './types'
export function buildFrameRestoreSnapshotFields(
serializer: SerializeAddon,
terminal: Terminal,
modes: TerminalModes
): { frameRestoreAnsi?: string } {
return modes.alternateScreen
? {
frameRestoreAnsi: buildTerminalFrameRestoreSequences(
serializer,
terminal,
modes,
readSavedCursorRegister(terminal)
)
}
: {}
}
/** Restores live terminal state without replaying an alternate-screen frame. */
function buildTerminalFrameRestoreSequences(
serializer: SerializeAddon,
terminal: Terminal,
modes: TerminalModes,
savedCursor: SavedCursorRegister | null
): string {
const terminalModes = terminal.modes
const seqs: string[] = ['\x1b[0m\x1b[?1049h', serializeCurrentSgrState(serializer, terminal)]
if (terminalModes.applicationCursorKeysMode) {
seqs.push('\x1b[?1h')
}
if (terminalModes.applicationKeypadMode) {
seqs.push('\x1b[?66h')
}
if (terminalModes.bracketedPasteMode) {
seqs.push('\x1b[?2004h')
}
if (terminalModes.insertMode) {
seqs.push('\x1b[4h')
}
if (terminalModes.reverseWraparoundMode) {
seqs.push('\x1b[?45h')
}
if (terminalModes.sendFocusMode) {
seqs.push('\x1b[?1004h')
}
if (!terminalModes.wraparoundMode) {
seqs.push('\x1b[?7l')
}
switch (modes.mouseTracking ? (modes.mouseTrackingMode ?? 'vt200') : 'none') {
case 'x10':
seqs.push('\x1b[?9h')
break
case 'vt200':
seqs.push('\x1b[?1000h')
break
case 'drag':
seqs.push('\x1b[?1002h')
break
case 'any':
seqs.push('\x1b[?1003h')
break
case 'none':
break
}
if (modes.sgrMousePixelsMode) {
seqs.push('\x1b[?1016h')
} else if (modes.sgrMouseMode) {
seqs.push('\x1b[?1006h')
}
if (!terminalModes.showCursor) {
seqs.push('\x1b[?25l')
}
seqs.push(
buildAbsoluteCursorRestoreSequence(terminal, savedCursor, {
restoreModesWithoutCursor: true
})
)
return seqs.join('')
}
function serializeCurrentSgrState(serializer: SerializeAddon, terminal: Terminal): string {
// Unsupported addon behavior under our vendored patch: an out-of-range row emits only its authoritative pen-state diff.
const emptyRow = terminal.buffer.normal.length
return serializer.serialize({
range: { start: emptyRow, end: emptyRow },
excludeAltBuffer: true,
excludeModes: true
})
}
@@ -70,6 +70,24 @@ async function roundTripStyles(source: string): Promise<Terminal> {
return replay(addon.serialize())
}
describe('out-of-range SGR state serialization', () => {
it('emits only the live pen state for a row beyond the normal buffer', async () => {
const { terminal, addon } = createTerminal()
await write(terminal, '\x1b[31;44;1m')
// This deliberately pins unsupported SerializeAddon behavior used by
// terminal-frame-restore-sequences under Orca's vendored addon patch.
const outOfRangeRow = terminal.buffer.normal.length
expect(
addon.serialize({
range: { start: outOfRangeRow, end: outOfRangeRow },
excludeAltBuffer: true,
excludeModes: true
})
).toBe('\x1b[31;44;1m')
})
})
describe('SGR intensity round-trip (BUG B, addon patch)', () => {
it('restores bold set immediately after dim is cleared (minimized repro)', async () => {
const restored = await roundTripStyles('\x1b[2mA\x1b[22m\x1b[1mB')
+2
View File
@@ -9,6 +9,8 @@ export type TerminalSnapshot = {
scrollbackAnsi: string
oscLinks?: TerminalOscLinkRange[]
rehydrateSequences: string
/** Live modes and cursor state that can be restored without the alt frame. */
frameRestoreAnsi?: string
cwd: string | null
modes: TerminalModes
cols: number
-17
View File
@@ -383,23 +383,6 @@ export type DaemonSessionInfo = SessionInfo & {
// existing importers keep one types entry point.
export * from './daemon-stream-events'
// ─── Binary Frame Protocol (Daemon ↔ PTY Subprocess) ────────────────
//
// 5-byte header: [type:1][length:4 big-endian]
// Followed by `length` bytes of payload.
export const enum FrameType {
Data = 0x01,
Resize = 0x02,
Exit = 0x03,
Error = 0x04,
Kill = 0x05,
Signal = 0x06
}
export const FRAME_HEADER_SIZE = 5
export const FRAME_MAX_PAYLOAD = 1024 * 1024 // 1MB
// ─── Notify prefix ──────────────────────────────────────────────────
// Requests with IDs starting with this prefix are fire-and-forget:
// the daemon processes them but does not send a response.
+5
View File
@@ -5,6 +5,7 @@ import {
writeHooksJson,
writeManagedScript
} from '../agent-hooks/installer-utils'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import {
readTextFileRemote,
writeHooksJsonRemote,
@@ -79,6 +80,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
}
export class DevinHookService {
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(getDevinManagedScriptPath(), getManagedScript())
}
getStatus(): AgentHookInstallStatus {
const configPath = getDevinConfigPath()
const scriptPath = getDevinManagedScriptPath()
+5
View File
@@ -16,6 +16,7 @@ import {
type HookDefinition,
type HooksConfig
} from '../agent-hooks/installer-utils'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import {
readHooksJsonRemote,
writeHooksJsonRemote,
@@ -159,6 +160,10 @@ function buildInstalledDroidConfig(
}
export class DroidHookService {
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript())
}
getStatus(): AgentHookInstallStatus {
const configPath = getConfigPath()
const scriptPath = getManagedScriptPath()
@@ -1,205 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
SCRCPY_CONTROL_MSG_TYPE,
encodeBackOrScreenOn,
encodeInjectKeycode,
encodeInjectText,
encodeInjectTouchEvent
} from './scrcpy-control-protocol'
describe('SCRCPY_CONTROL_MSG_TYPE', () => {
it('pins the v2.4 control message type codes', () => {
expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_KEYCODE).toBe(0)
expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_TEXT).toBe(1)
expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_TOUCH_EVENT).toBe(2)
expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_SCROLL_EVENT).toBe(3)
expect(SCRCPY_CONTROL_MSG_TYPE.BACK_OR_SCREEN_ON).toBe(4)
})
})
describe('encodeInjectTouchEvent', () => {
it('encodes a touch-down event with the full 32-byte field layout', () => {
const buf = encodeInjectTouchEvent({
action: 'down',
pointerId: 0x1122334455667788n,
x: 100,
y: 200,
screenWidth: 1080,
screenHeight: 1920
})
expect(buf.length).toBe(32)
expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.INJECT_TOUCH_EVENT)
expect(buf.readUInt8(0)).toBe(2)
expect(buf.readUInt8(1)).toBe(0) // AMOTION_EVENT_ACTION_DOWN
expect(buf.readBigUInt64BE(2)).toBe(0x1122334455667788n)
expect(buf.readInt32BE(10)).toBe(100)
expect(buf.readInt32BE(14)).toBe(200)
expect(buf.readUInt16BE(18)).toBe(1080)
expect(buf.readUInt16BE(20)).toBe(1920)
expect(buf.readUInt16BE(22)).toBe(0xffff) // default pressure 1.0 for down
expect(buf.readUInt32BE(24)).toBe(1) // AMOTION_EVENT_BUTTON_PRIMARY
expect(buf.readUInt32BE(28)).toBe(1) // buttons set while pressed
})
it('encodes a move event with action code 2 and full default pressure', () => {
const buf = encodeInjectTouchEvent({
action: 'move',
pointerId: 1n,
x: 5,
y: 6,
screenWidth: 720,
screenHeight: 1280
})
expect(buf.length).toBe(32)
expect(buf.readUInt8(1)).toBe(2) // AMOTION_EVENT_ACTION_MOVE
expect(buf.readUInt16BE(22)).toBe(0xffff)
expect(buf.readUInt32BE(28)).toBe(1)
})
it('encodes an up event with zero default pressure and cleared buttons', () => {
const buf = encodeInjectTouchEvent({
action: 'up',
pointerId: 1n,
x: 5,
y: 6,
screenWidth: 720,
screenHeight: 1280
})
expect(buf.length).toBe(32)
expect(buf.readUInt8(1)).toBe(1) // AMOTION_EVENT_ACTION_UP
expect(buf.readUInt16BE(22)).toBe(0) // default pressure 0 on up
expect(buf.readUInt32BE(24)).toBe(1) // actionButton stays primary
expect(buf.readUInt32BE(28)).toBe(0) // buttons cleared on up
})
it('writes negative coordinates as signed int32', () => {
const buf = encodeInjectTouchEvent({
action: 'move',
pointerId: 1n,
x: -3,
y: -7,
screenWidth: 1,
screenHeight: 1
})
expect(buf.readInt32BE(10)).toBe(-3)
expect(buf.readInt32BE(14)).toBe(-7)
})
it('encodes explicit fractional pressure as fixed-point u16', () => {
const buf = encodeInjectTouchEvent({
action: 'down',
pointerId: 1n,
x: 0,
y: 0,
screenWidth: 1,
screenHeight: 1,
pressure: 0.5
})
expect(buf.readUInt16BE(22)).toBe(Math.round(0.5 * 0xffff))
})
it('clamps pressure above 1 and below 0', () => {
const high = encodeInjectTouchEvent({
action: 'down',
pointerId: 1n,
x: 0,
y: 0,
screenWidth: 1,
screenHeight: 1,
pressure: 2
})
expect(high.readUInt16BE(22)).toBe(0xffff)
const low = encodeInjectTouchEvent({
action: 'down',
pointerId: 1n,
x: 0,
y: 0,
screenWidth: 1,
screenHeight: 1,
pressure: -1
})
expect(low.readUInt16BE(22)).toBe(0)
})
})
describe('encodeInjectKeycode', () => {
it('encodes a keycode-down event (14 bytes) with round-tripped fields', () => {
const buf = encodeInjectKeycode({
action: 'down',
keycode: 66, // AKEYCODE_ENTER
repeat: 3,
metaState: 0x1000
})
expect(buf.length).toBe(14)
expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.INJECT_KEYCODE)
expect(buf.readUInt8(0)).toBe(0)
expect(buf.readUInt8(1)).toBe(0) // AKEY_EVENT_ACTION_DOWN
expect(buf.readInt32BE(2)).toBe(66)
expect(buf.readInt32BE(6)).toBe(3)
expect(buf.readInt32BE(10)).toBe(0x1000)
})
it('encodes a keycode-up event and applies repeat/metaState defaults', () => {
const buf = encodeInjectKeycode({ action: 'up', keycode: 4 }) // AKEYCODE_BACK
expect(buf.length).toBe(14)
expect(buf.readUInt8(1)).toBe(1) // AKEY_EVENT_ACTION_UP
expect(buf.readInt32BE(2)).toBe(4)
expect(buf.readInt32BE(6)).toBe(0) // default repeat
expect(buf.readInt32BE(10)).toBe(0) // default metaState
})
})
describe('encodeInjectText', () => {
it('encodes the type byte, UTF-8 byte length header, and payload', () => {
const buf = encodeInjectText('hello')
expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.INJECT_TEXT)
expect(buf.readUInt8(0)).toBe(1)
expect(buf.readUInt32BE(1)).toBe(5)
expect(buf.subarray(5).toString('utf8')).toBe('hello')
expect(buf.length).toBe(5 + 5)
})
it('uses UTF-8 byte length (not string length) for multi-byte characters', () => {
const text = 'é' // one JS char, two UTF-8 bytes
const buf = encodeInjectText(text)
expect(text.length).toBe(1)
expect(buf.readUInt32BE(1)).toBe(2)
expect(buf.length).toBe(5 + 2)
expect(buf.subarray(5).toString('utf8')).toBe('é')
})
it('encodes an empty string as a 5-byte header with zero length', () => {
const buf = encodeInjectText('')
expect(buf.length).toBe(5)
expect(buf.readUInt32BE(1)).toBe(0)
})
})
describe('encodeBackOrScreenOn', () => {
it('encodes a 2-byte down message', () => {
const buf = encodeBackOrScreenOn('down')
expect(buf.length).toBe(2)
expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.BACK_OR_SCREEN_ON)
expect(buf.readUInt8(0)).toBe(4)
expect(buf.readUInt8(1)).toBe(0) // AKEY_EVENT_ACTION_DOWN
})
it('encodes a 2-byte up message', () => {
const buf = encodeBackOrScreenOn('up')
expect(buf.length).toBe(2)
expect(buf.readUInt8(1)).toBe(1) // AKEY_EVENT_ACTION_UP
})
})
@@ -1,91 +0,0 @@
// scrcpy server control protocol v2.4
// Byte-exact encoders for the scrcpy control socket. Every multi-byte field is
// big-endian to match the server's DataInputStream reads.
export const SCRCPY_CONTROL_MSG_TYPE = {
INJECT_KEYCODE: 0,
INJECT_TEXT: 1,
INJECT_TOUCH_EVENT: 2,
INJECT_SCROLL_EVENT: 3,
BACK_OR_SCREEN_ON: 4
} as const
export type ScrcpyKeyAction = 'down' | 'up'
export type ScrcpyTouchAction = 'down' | 'up' | 'move'
// AKEY_EVENT_ACTION_* / AMOTION_EVENT_ACTION_* codes the server forwards to Android.
const KEY_ACTION_CODE: Record<ScrcpyKeyAction, number> = { down: 0, up: 1 }
const TOUCH_ACTION_CODE: Record<ScrcpyTouchAction, number> = { down: 0, up: 1, move: 2 }
// AMOTION_EVENT_BUTTON_PRIMARY — scrcpy reports the primary button for touches.
const BUTTON_PRIMARY = 1
// Pressure is a u16 fixed-point value where 0xFFFF represents 1.0.
const PRESSURE_MAX = 0xffff
function encodePressure(pressure: number): number {
const fixed = Math.round(pressure * PRESSURE_MAX)
if (fixed < 0) {
return 0
}
if (fixed > PRESSURE_MAX) {
return PRESSURE_MAX
}
return fixed
}
export function encodeInjectTouchEvent(p: {
action: ScrcpyTouchAction
pointerId: bigint
x: number
y: number
screenWidth: number
screenHeight: number
pressure?: number
}): Buffer {
// A finger that lifts has no pressure; otherwise full pressure unless overridden.
const pressure = p.pressure ?? (p.action === 'up' ? 0 : 1)
const buf = Buffer.alloc(32)
buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.INJECT_TOUCH_EVENT, 0)
buf.writeUInt8(TOUCH_ACTION_CODE[p.action], 1)
buf.writeBigUInt64BE(p.pointerId, 2)
buf.writeInt32BE(p.x, 10)
buf.writeInt32BE(p.y, 14)
buf.writeUInt16BE(p.screenWidth, 18)
buf.writeUInt16BE(p.screenHeight, 20)
buf.writeUInt16BE(encodePressure(pressure), 22)
buf.writeUInt32BE(BUTTON_PRIMARY, 24)
buf.writeUInt32BE(p.action === 'up' ? 0 : BUTTON_PRIMARY, 28)
return buf
}
export function encodeInjectKeycode(p: {
action: ScrcpyKeyAction
keycode: number
repeat?: number
metaState?: number
}): Buffer {
const buf = Buffer.alloc(14)
buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.INJECT_KEYCODE, 0)
buf.writeUInt8(KEY_ACTION_CODE[p.action], 1)
buf.writeInt32BE(p.keycode, 2)
buf.writeInt32BE(p.repeat ?? 0, 6)
buf.writeInt32BE(p.metaState ?? 0, 10)
return buf
}
export function encodeInjectText(text: string): Buffer {
// The length prefix is the UTF-8 byte count, which can exceed text.length.
const payload = Buffer.from(text, 'utf8')
const buf = Buffer.alloc(5 + payload.length)
buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.INJECT_TEXT, 0)
buf.writeUInt32BE(payload.length, 1)
payload.copy(buf, 5)
return buf
}
export function encodeBackOrScreenOn(action: ScrcpyKeyAction): Buffer {
const buf = Buffer.alloc(2)
buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.BACK_OR_SCREEN_ON, 0)
buf.writeUInt8(KEY_ACTION_CODE[action], 1)
return buf
}
@@ -22,8 +22,7 @@ import { emulatorProbe, emulatorProbeError } from '../emulator-probe'
// A live scrcpy session validated against a real emulator. The connection
// handshake (dummy byte, 64-byte device name, codec meta) and the server option
// set are coupled to the pinned scrcpy server version; the pure framing
// (scrcpy-video-frame-parser) and control encoding (scrcpy-control-protocol) are
// set are coupled to the pinned scrcpy server version; pure video framing is
// unit-tested, while this orchestration is exercised live via probes.
const DEVICE_NAME_BYTES = 64
@@ -198,7 +197,6 @@ export class ScrcpyStreamSession {
socket.on('error', (error) =>
emulatorProbeError('scrcpy.control.fail', error, { serial: this.options.serial })
)
// Drop the ref on reset so sendControl() can't write to a dead socket (half-open session).
socket.on('close', () => {
if (this.controlSocket === socket) {
this.controlSocket = null
@@ -253,11 +251,6 @@ export class ScrcpyStreamSession {
}
}
// Sends an encoded scrcpy control message (see scrcpy-control-protocol).
sendControl(message: Buffer): void {
this.controlSocket?.write(message)
}
private fail(message: string): void {
if (this.closed) {
return
+5
View File
@@ -16,6 +16,7 @@ import {
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import {
readHooksJsonRemote,
writeHooksJsonRemote,
@@ -97,6 +98,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
}
export class GeminiHookService {
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript())
}
getStatus(): AgentHookInstallStatus {
const configPath = getConfigPath()
const scriptPath = getManagedScriptPath()
+38 -2
View File
@@ -1,13 +1,19 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
clearGitCapabilityStateForTests,
getLocalGitCapabilityCache,
getSshGitCapabilityCache
getSshGitCapabilityCache,
withLocalGitCapabilityCacheForExecution
} from './git-capability-state'
import {
resetWslLinkedWorktreeGitRoutingForTests,
seedWslLinkedWorktreeGitRoutingForTests
} from './wsl-linked-worktree-git-routing'
describe('Git capability execution-host state', () => {
beforeEach(() => {
clearGitCapabilityStateForTests()
resetWslLinkedWorktreeGitRoutingForTests()
})
it('shares native state while isolating each WSL distro', () => {
@@ -34,4 +40,34 @@ describe('Git capability execution-host state', () => {
getSshGitCapabilityCache(replacementProvider)
)
})
it('uses native capability state for a prepared host-routed WSL worktree', async () => {
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
try {
seedWslLinkedWorktreeGitRoutingForTests(String.raw`C:\repo\linked`)
await expect(
withLocalGitCapabilityCacheForExecution(
{ cwd: String.raw`C:\repo\linked`, wslDistro: 'Ubuntu' },
async (capabilities) => capabilities
)
).resolves.toBe(getLocalGitCapabilityCache())
expect(getLocalGitCapabilityCache()).not.toBe(
getLocalGitCapabilityCache({ wslDistro: 'Ubuntu' })
)
} finally {
platform.mockRestore()
}
})
it('starts non-candidate capability work without an added async turn', async () => {
let started = false
const result = withLocalGitCapabilityCacheForExecution({ cwd: '/repo' }, async () => {
started = true
return 'done'
})
expect(started).toBe(true)
await expect(result).resolves.toBe('done')
})
})
+27
View File
@@ -1,9 +1,14 @@
import { GitCapabilityCache } from '../../shared/git-capability-cache'
import { parseWslUncPath } from '../../shared/wsl-paths'
import {
isWslLinkedWorktreeGitRoutingCandidate,
prepareWslLinkedWorktreeGitRouting
} from './wsl-linked-worktree-git-routing'
type LocalGitCapabilityTarget = {
cwd?: string
wslDistro?: string
signal?: AbortSignal
}
const localCapabilitiesByExecutionHost = new Map<string, GitCapabilityCache>()
@@ -29,6 +34,28 @@ export function getLocalGitCapabilityCache(
return cache
}
export function withLocalGitCapabilityCacheForExecution<T>(
target: LocalGitCapabilityTarget,
run: (capabilities: GitCapabilityCache) => Promise<T>
): Promise<T> {
if (!target.cwd || !isWslLinkedWorktreeGitRoutingCandidate(target.cwd, target.wslDistro)) {
try {
return run(getLocalGitCapabilityCache(target))
} catch (error) {
return Promise.reject(error)
}
}
return prepareWslLinkedWorktreeGitRouting(target.cwd, target.wslDistro, {
signal: target.signal
}).then((usesHostGit) =>
run(
getLocalGitCapabilityCache(
usesHostGit ? { cwd: target.cwd } : { cwd: target.cwd, wslDistro: target.wslDistro }
)
)
)
}
export function getSshGitCapabilityCache(provider: object): GitCapabilityCache {
let cache = sshCapabilitiesByProvider.get(provider)
if (!cache) {
+130 -1
View File
@@ -17,12 +17,18 @@ vi.mock('../observability/instrumentation', () => ({
}))
vi.mock('../diagnostics/main-thread-churn-probe', () => ({ recordSubprocessSpawn: vi.fn() }))
import { gitExecFileAsync, gitStreamStdout } from './runner'
import { gitExecFileAsync, gitSpawn, gitStreamStdout } from './runner'
import {
getWslGitReadEnvironment,
resetWslGitReadEnvironmentForTests,
seedWslGitReadEnvironmentForTests
} from './wsl-git-read-environment'
import {
prepareWslLinkedWorktreeGitRouting,
resetWslLinkedWorktreeGitRoutingForTests,
WSL_LINKED_WORKTREE_ROUTE_TTL_MS,
type WslLinkedWorktreeRoutingFileSystem
} from './wsl-linked-worktree-git-routing'
const DISTRO = 'Ubuntu'
const LOGIN_ENVIRONMENT = {
@@ -71,10 +77,12 @@ describe('WSL direct Git reads', () => {
execFileSyncMock.mockReset()
spawnMock.mockReset()
resetWslGitReadEnvironmentForTests()
resetWslLinkedWorktreeGitRoutingForTests()
})
afterEach(() => {
resetWslGitReadEnvironmentForTests()
resetWslLinkedWorktreeGitRoutingForTests()
})
it('coalesces the login-shell environment probe per distro', async () => {
@@ -495,4 +503,125 @@ describe('WSL direct Git reads', () => {
expect(execFileMock.mock.calls[0]?.[0]).toBe('git')
})
})
it('keeps gitSpawn cache-only while async linked-worktree discovery is pending', async () => {
await withPlatform('win32', async () => {
let releaseStat: (() => void) | undefined
const delayedStat = new Promise<void>((resolve) => {
releaseStat = resolve
})
const fileSystem: WslLinkedWorktreeRoutingFileSystem = {
stat: vi.fn(async () => {
await delayedStat
return { isDirectory: () => false, isFile: () => true }
}),
readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n')
}
const pending = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, DISTRO, {
platform: 'win32',
fileSystem
})
spawnMock.mockReturnValue(createMockChild())
gitSpawn(['ls-files'], {
cwd: String.raw`C:\repo`,
wslDistro: DISTRO,
stdio: ['ignore', 'pipe', 'pipe']
})
expect(fileSystem.stat).toHaveBeenCalledTimes(1)
expect(spawnMock.mock.calls[0]?.[0]).toBe('wsl.exe')
releaseStat?.()
await expect(pending).resolves.toBe(true)
gitSpawn(['ls-files'], {
cwd: String.raw`C:\repo`,
wslDistro: DISTRO,
stdio: ['ignore', 'pipe', 'pipe']
})
expect(fileSystem.stat).toHaveBeenCalledTimes(1)
expect(spawnMock.mock.calls[1]?.[0]).toBe('git')
})
})
it('aborts an async Git call while linked-worktree discovery remains pending', async () => {
await withPlatform('win32', async () => {
let releaseStat: (() => void) | undefined
const delayedStat = new Promise<void>((resolve) => {
releaseStat = resolve
})
const fileSystem: WslLinkedWorktreeRoutingFileSystem = {
stat: vi.fn(async () => {
await delayedStat
return { isDirectory: () => false, isFile: () => true }
}),
readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n')
}
const discovery = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, DISTRO, {
platform: 'win32',
fileSystem
})
const controller = new AbortController()
const command = gitExecFileAsync(['status', '--short'], {
cwd: String.raw`C:\repo`,
wslDistro: DISTRO,
signal: controller.signal
})
controller.abort()
await expect(command).rejects.toMatchObject({ name: 'AbortError' })
expect(execFileMock).not.toHaveBeenCalled()
releaseStat?.()
await expect(discovery).resolves.toBe(true)
})
})
it('keeps gitSpawn cache-only when a linked-worktree route expires', async () => {
await withPlatform('win32', async () => {
let currentTime = 1_000
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => currentTime)
try {
const fileSystem: WslLinkedWorktreeRoutingFileSystem = {
stat: vi.fn(async () => ({ isDirectory: () => false, isFile: () => true })),
readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n')
}
await prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, DISTRO, {
platform: 'win32',
fileSystem
})
spawnMock.mockReturnValue(createMockChild())
gitSpawn(['ls-files'], {
cwd: String.raw`C:\repo`,
wslDistro: DISTRO,
stdio: ['ignore', 'pipe', 'pipe']
})
currentTime += WSL_LINKED_WORKTREE_ROUTE_TTL_MS
gitSpawn(['ls-files'], {
cwd: String.raw`C:\repo`,
wslDistro: DISTRO,
stdio: ['ignore', 'pipe', 'pipe']
})
expect(spawnMock.mock.calls[0]?.[0]).toBe('git')
expect(spawnMock.mock.calls[1]?.[0]).toBe('wsl.exe')
expect(fileSystem.stat).toHaveBeenCalledTimes(1)
await prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, DISTRO, {
platform: 'win32',
fileSystem
})
gitSpawn(['ls-files'], {
cwd: String.raw`C:\repo`,
wslDistro: DISTRO,
stdio: ['ignore', 'pipe', 'pipe']
})
expect(fileSystem.stat).toHaveBeenCalledTimes(2)
expect(spawnMock.mock.calls[2]?.[0]).toBe('git')
} finally {
nowSpy.mockRestore()
}
})
})
})
@@ -0,0 +1,98 @@
import { EventEmitter } from 'node:events'
import type * as FsPromises from 'node:fs/promises'
import { afterEach, describe, expect, it, vi } from 'vitest'
const { execFileMock, statMock, readFileMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
statMock: vi.fn(),
readFileMock: vi.fn()
}))
vi.mock('node:child_process', () => ({
execFile: execFileMock,
execFileSync: vi.fn(),
spawn: vi.fn()
}))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof FsPromises>()),
stat: statMock,
readFile: readFileMock
}))
import { gitExecFileAsync } from './runner'
import {
resetWslLinkedWorktreeGitRoutingForTests,
WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS
} from './wsl-linked-worktree-git-routing'
function createMockChild(): EventEmitter & { pid: number; kill: ReturnType<typeof vi.fn> } {
const child = new EventEmitter() as EventEmitter & {
pid: number
kill: ReturnType<typeof vi.fn>
}
child.pid = 1234
child.kill = vi.fn()
return child
}
async function withWindowsPlatform(run: () => Promise<void>): Promise<void> {
const original = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
await run()
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: original })
}
}
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
resetWslLinkedWorktreeGitRoutingForTests()
})
describe('WSL linked-worktree routing probe timeout', () => {
it('retries discovery on the next production Git call', async () => {
vi.useFakeTimers()
await withWindowsPlatform(async () => {
statMock
.mockImplementationOnce(() => new Promise(() => {}))
.mockResolvedValue({ isDirectory: () => false, isFile: () => true })
readFileMock.mockResolvedValue('gitdir: C:/main/.git/worktrees/linked\n')
execFileMock.mockImplementation((command, _args, _options, callback) => {
const child = createMockChild()
queueMicrotask(() => {
if (command === 'wsl.exe') {
callback?.(
Object.assign(new Error('not a git repository'), { code: 128 }),
'',
'fatal: not a git repository'
)
} else {
callback?.(null, 'host git recovered\n', '')
}
})
return child
})
const first = gitExecFileAsync(['status', '--short'], {
cwd: String.raw`C:\repo`,
wslDistro: 'Ubuntu'
})
const firstFailure = expect(first).rejects.toThrow('not a git repository')
await vi.advanceTimersByTimeAsync(WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS)
await firstFailure
await expect(
gitExecFileAsync(['status', '--short'], {
cwd: String.raw`C:\repo`,
wslDistro: 'Ubuntu'
})
).resolves.toEqual({ stdout: 'host git recovered\n', stderr: '' })
expect(statMock).toHaveBeenCalledTimes(2)
expect(execFileMock.mock.calls.map(([command]) => command)).toEqual(['wsl.exe', 'git'])
})
})
})
@@ -0,0 +1,161 @@
import { execFileSync } from 'node:child_process'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { listFilesWithGit } from '../ipc/filesystem-list-files-git-fallback'
import { searchWithGitGrep } from '../ipc/filesystem-search-git'
import {
gitExecFileAsync,
gitExecFileAsyncBuffer,
gitStreamStdout,
toLinuxPath,
toWindowsWslPath
} from './runner'
import { listWorktrees } from './worktree'
import { resetWslLinkedWorktreeGitRoutingForTests } from './wsl-linked-worktree-git-routing'
const distro = process.env.ORCA_TEST_WSL_DISTRO?.trim()
const fixtureRoots: string[] = []
const wslFixtureRoots: string[] = []
function hostGit(args: string[], cwd: string): string {
return execFileSync('git', args, { cwd, encoding: 'utf8' })
}
function wslExec(args: string[]): string {
return execFileSync('wsl.exe', ['-d', distro!, '--exec', ...args], { encoding: 'utf8' })
}
afterEach(async () => {
await Promise.all(
fixtureRoots.splice(0).map((path) => rm(path, { recursive: true, force: true }))
)
for (const path of wslFixtureRoots.splice(0)) {
if (!/^\/tmp\/orca-wsl-native-[A-Za-z0-9]+$/.test(path)) {
throw new Error(`Refusing to remove unexpected WSL fixture path: ${path}`)
}
wslExec(['rm', '-rf', '--', path])
}
})
describe.runIf(process.platform === 'win32' && Boolean(distro))(
'Windows-authored linked worktree in WSL',
() => {
it('handles the worktree through the production Git runner', async () => {
const fixtureRoot = await mkdtemp(join(process.cwd(), '.wsl-linked-gitdir-'))
fixtureRoots.push(fixtureRoot)
const mainPath = join(fixtureRoot, 'main')
const linkedPath = join(fixtureRoot, 'linked')
hostGit(['init', mainPath], fixtureRoot)
hostGit(['config', 'user.name', 'Orca Test'], mainPath)
hostGit(['config', 'user.email', 'test@invalid'], mainPath)
await writeFile(join(mainPath, 'tracked.txt'), 'tracked\n')
hostGit(['add', 'tracked.txt'], mainPath)
hostGit(['commit', '-m', 'fixture'], mainPath)
hostGit(['worktree', 'add', '-b', 'linked', linkedPath], mainPath)
await writeFile(join(linkedPath, 'untracked.txt'), 'untracked\n')
const gitFile = await readFile(join(linkedPath, '.git'), 'utf8')
expect(gitFile).toMatch(/^gitdir: [A-Za-z]:\//)
expect(() =>
execFileSync(
'wsl.exe',
['-d', distro!, '--exec', 'git', '-C', toLinuxPath(linkedPath), 'status', '--short'],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
)
).toThrow(/not a git repository/i)
await expect(listFilesWithGit(linkedPath, [], { wslDistro: distro! })).resolves.toEqual(
expect.arrayContaining(['tracked.txt', 'untracked.txt'])
)
resetWslLinkedWorktreeGitRoutingForTests()
await expect(
searchWithGitGrep(linkedPath, { query: 'tracked', rootPath: linkedPath }, 10, {
wslDistro: distro!
})
).resolves.toMatchObject({ totalMatches: 2 })
let streamedStatus = ''
await expect(
gitStreamStdout(['status', '--short'], {
cwd: linkedPath,
wslDistro: distro!,
onStdout: (chunk) => {
streamedStatus += chunk
}
})
).resolves.toEqual({ stoppedEarly: false })
expect(streamedStatus).toBe('?? untracked.txt\n')
await gitExecFileAsync(['add', '--', 'untracked.txt'], {
cwd: linkedPath,
wslDistro: distro!
})
await expect(
gitExecFileAsync(['status', '--short'], { cwd: linkedPath, wslDistro: distro! })
).resolves.toMatchObject({ stdout: 'A untracked.txt\n' })
await expect(
gitExecFileAsync(['status', '--short'], { cwd: linkedPath })
).resolves.toMatchObject({ stdout: 'A untracked.txt\n' })
await expect(
gitExecFileAsyncBuffer(['show', ':untracked.txt'], {
cwd: linkedPath,
wslDistro: distro!
})
).resolves.toMatchObject({ stdout: Buffer.from('untracked\n') })
const folderPath = join(linkedPath, 'packages', 'app')
await mkdir(folderPath, { recursive: true })
await expect(
gitExecFileAsync(['rev-parse', '--show-toplevel'], {
cwd: folderPath,
wslDistro: distro!
})
).resolves.toMatchObject({ stdout: `${linkedPath.replace(/\\/g, '/')}\n` })
const linkedExecPath = await gitExecFileAsync(['--exec-path'], {
cwd: linkedPath,
wslDistro: distro!
})
const mainExecPath = await gitExecFileAsync(['--exec-path'], {
cwd: mainPath,
wslDistro: distro!
})
expect(linkedExecPath.stdout).toMatch(/^[A-Za-z]:\//)
expect(mainExecPath.stdout).toMatch(/(?:^|\n)\/[^\n]+\n$/)
await expect(listWorktrees(linkedPath, { wslDistro: distro! })).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ path: expect.stringMatching(/[\\/]linked$/) })
])
)
const nestedPath = join(linkedPath, 'nested')
wslExec(['git', 'init', toLinuxPath(nestedPath)])
await expect(
gitExecFileAsync(['--exec-path'], { cwd: nestedPath, wslDistro: distro! })
).resolves.toMatchObject({ stdout: expect.stringMatching(/(?:^|\n)\/[^\n]+\n$/) })
})
it('keeps WSL-native repositories on WSL Git', async () => {
const linuxRoot = wslExec(['mktemp', '-d', '/tmp/orca-wsl-native-XXXXXX']).trim()
wslFixtureRoots.push(linuxRoot)
const windowsRoot = toWindowsWslPath(linuxRoot, distro!)
wslExec(['git', 'init', linuxRoot])
wslExec(['git', '-C', linuxRoot, 'config', 'user.name', 'Orca Test'])
wslExec(['git', '-C', linuxRoot, 'config', 'user.email', 'test@invalid'])
await writeFile(join(windowsRoot, 'tracked.txt'), 'tracked\n')
wslExec(['git', '-C', linuxRoot, 'add', 'tracked.txt'])
wslExec(['git', '-C', linuxRoot, 'commit', '-m', 'fixture'])
await writeFile(join(windowsRoot, 'untracked.txt'), 'untracked\n')
await expect(
gitExecFileAsync(['status', '--short'], { cwd: windowsRoot, wslDistro: distro! })
).resolves.toMatchObject({ stdout: expect.stringMatching(/(?:^|\n)\?\? untracked\.txt\n$/) })
await expect(
gitExecFileAsync(['--exec-path'], { cwd: windowsRoot, wslDistro: distro! })
).resolves.toMatchObject({ stdout: expect.stringMatching(/(?:^|\n)\/[^\n]+\n$/) })
})
}
)
+28 -12
View File
@@ -48,6 +48,11 @@ import {
peekWslGitReadEnvironment,
type WslGitReadEnvironment
} from './wsl-git-read-environment'
import {
isWslLinkedWorktreeGitRoutingCandidate,
prepareWslLinkedWorktreeGitRouting,
usesHostGitForWslLinkedWorktree
} from './wsl-linked-worktree-git-routing'
// Re-exported for existing importers; lightweight consumers should import from './exec-error' to avoid this heavy module.
import { extractExecError, parseRetryAfterMs } from './exec-error'
export { extractExecError, parseRetryAfterMs }
@@ -335,6 +340,10 @@ function resolveGitCommand(
options: GitExecOptions,
forceLoginShell = false
): ResolvedCommand {
if (usesHostGitForWslLinkedWorktree(options.cwd, options.wslDistro)) {
// Why: WSL Git resolves a Windows-authored linked-worktree pointer relative to cwd.
return { binary: 'git', args, cwd: options.cwd, wsl: null, wslMode: null }
}
if (!forceLoginShell && shouldAttemptWslDirectGit(options)) {
const distro = wslDistroForCommand(options.cwd, options.wslDistro)
const environment = distro ? peekWslGitReadEnvironment(distro) : undefined
@@ -908,13 +917,7 @@ async function buildNetworkSshPolicyEnv(options: GitExecOptions): Promise<{
return { env: promptEnv, mode: 'explicit-env' }
}
const resolved = resolveCommand(
'git',
['config', '--get', 'core.sshCommand'],
options.cwd,
options.wslDistro,
{ useWslLoginShell: Boolean(options.wslDistro) }
)
const resolved = resolveGitCommand(['config', '--get', 'core.sshCommand'], options, true)
let configuredCommand = ''
try {
const { stdout } = await execFileCapture(resolved.binary, resolved.args, {
@@ -964,6 +967,11 @@ export async function gitExecFileAsync(
return withGitSpan(
{ args, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}) },
async () => {
if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) {
await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, {
signal: options.signal
})
}
const resolved = resolveGitCommand(args, options)
const policy = options.useConfiguredSshCommandForNetwork
? await buildNetworkSshPolicyEnv(options)
@@ -1055,9 +1063,10 @@ export async function gitExecFileAsyncBuffer(
args: string[],
options: { cwd: string; maxBuffer?: number; wslDistro?: string }
): Promise<{ stdout: Buffer }> {
const resolved = resolveCommand('git', args, options.cwd, options.wslDistro, {
useWslLoginShell: Boolean(options.wslDistro)
})
if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) {
await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro)
}
const resolved = resolveGitCommand(args, options, true)
const { stdout } = (await execFileCapture(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: 'buffer',
@@ -1098,6 +1107,11 @@ export async function gitStreamStdout(
): Promise<GitStreamResult> {
const maxBuffer = options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER
return withGitSpan({ args, cwd: options.cwd }, async () => {
if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) {
await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, {
signal: options.signal
})
}
const gitOptions: GitExecOptions = {
cwd: options.cwd,
...(options.env ? { env: options.env } : {}),
@@ -1294,8 +1308,10 @@ export function gitSpawn(
options: SpawnOptions & { cwd: string; wslDistro?: string }
): ChildProcess {
const { wslDistro, ...spawnOptions } = options
const resolved = resolveCommand('git', args, options.cwd, wslDistro, {
useWslLoginShell: Boolean(wslDistro)
const resolved = resolveGitCommand(args, {
cwd: options.cwd,
...(wslDistro ? { wslDistro } : {}),
...(spawnOptions.env ? { env: spawnOptions.env } : {})
})
const spawnStartedAt = performance.now()
const child = spawn(resolved.binary, resolved.args, {
@@ -0,0 +1,250 @@
import { performance } from 'node:perf_hooks'
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeFsPromises from 'node:fs/promises'
import type * as GitRunner from './runner'
import type { GitExec } from '../../relay/git-handler-ops'
import type { RelayGitStreamExec } from '../../relay/git-stdout-stream'
const { gitStreamStdoutMock, readFileMock } = vi.hoisted(() => ({
gitStreamStdoutMock: vi.fn(),
readFileMock: vi.fn()
}))
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFsPromises>()
return { ...actual, readFile: readFileMock }
})
vi.mock('./runner', async (importOriginal) => {
const actual = await importOriginal<typeof GitRunner>()
return { ...actual, gitStreamStdout: gitStreamStdoutMock }
})
import { getStatus } from './status'
import { getStatusOp } from '../../relay/git-handler-status-ops'
const BENCH_DELAY_MS = 25
const BENCH_SAMPLES = 31
const BENCH_WARMUPS = 5
const describeBench = process.env.ORCA_GIT_STATUS_OVERLAP_BENCH === '1' ? describe : describe.skip
type Deferred<T> = {
promise: Promise<T>
resolve: (value: T) => void
}
type BenchmarkResult = {
path: string
medianMs: number
p95Ms: number
minMs: number
maxMs: number
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve
})
return { promise, resolve }
}
function wait(delayMs: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, delayMs))
}
function percentile(samples: number[], fraction: number): number {
const sorted = [...samples].sort((a, b) => a - b)
return sorted[Math.ceil(sorted.length * fraction) - 1]!
}
describe('git status conflict-read overlap', () => {
const relayGit: GitExec = async () => ({ stdout: '', stderr: '' })
beforeEach(() => {
readFileMock.mockReset()
gitStreamStdoutMock.mockReset()
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
gitStreamStdoutMock.mockResolvedValue({ stoppedEarly: false })
})
it('starts native status before conflict-marker I/O settles', async () => {
const markerRead = deferred<string>()
const statusStarted = deferred<void>()
readFileMock.mockReturnValue(markerRead.promise)
gitStreamStdoutMock.mockImplementation(async () => {
statusStarted.resolve()
return { stoppedEarly: false }
})
const resultPromise = getStatus('/repo')
await statusStarted.promise
expect(readFileMock).toHaveBeenCalledWith(join('/repo', '.git'), 'utf-8')
markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n')
await expect(resultPromise).resolves.toMatchObject({
entries: [],
conflictOperation: 'unknown'
})
})
it('starts relay status before conflict-marker I/O settles', async () => {
const markerRead = deferred<string>()
const statusStarted = deferred<void>()
readFileMock.mockReturnValue(markerRead.promise)
const relayStreamGit: RelayGitStreamExec = async () => {
statusStarted.resolve()
return { stoppedEarly: false }
}
const resultPromise = getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' })
await statusStarted.promise
expect(readFileMock).toHaveBeenCalledWith(join('/repo', '.git'), 'utf-8')
markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n')
await expect(resultPromise).resolves.toMatchObject({
entries: [],
conflictOperation: 'unknown'
})
})
it('observes an early native status rejection while marker I/O remains pending', async () => {
const markerRead = deferred<string>()
readFileMock.mockReturnValue(markerRead.promise)
gitStreamStdoutMock.mockRejectedValue(new Error('status failed first'))
let settled = false
const resultPromise = getStatus('/repo').finally(() => {
settled = true
})
await Promise.resolve()
await Promise.resolve()
expect(settled).toBe(false)
markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n')
await expect(resultPromise).resolves.toMatchObject({
entries: [],
conflictOperation: 'unknown'
})
})
it('keeps a synchronous native status failure fail-soft', async () => {
const statusError = new Error('status threw')
gitStreamStdoutMock.mockImplementation(() => {
throw statusError
})
await expect(getStatus('/repo')).resolves.toMatchObject({
entries: [],
conflictOperation: 'unknown'
})
})
it.each(['throw', 'reject'] as const)('keeps relay %s failures fail-soft', async (mode) => {
const statusError = new Error(`status ${mode}`)
const relayStreamGit = (() => {
if (mode === 'throw') {
throw statusError
}
return Promise.reject(statusError)
}) as RelayGitStreamExec
await expect(
getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' })
).resolves.toMatchObject({ entries: [], conflictOperation: 'unknown' })
})
it('rethrows the original relay status failure after cancellation', async () => {
const controller = new AbortController()
const statusError = new Error('cancelled status')
const relayStreamGit: RelayGitStreamExec = async () => {
controller.abort(statusError)
throw statusError
}
await expect(
getStatusOp(
relayGit,
relayStreamGit,
{ worktreePath: '/repo' },
{ signal: controller.signal }
)
).rejects.toBe(statusError)
})
it('surfaces detector errors without waiting for a hung status read', async () => {
const statusStarted = deferred<void>()
const relayStreamGit: RelayGitStreamExec = () => {
statusStarted.resolve()
return new Promise(() => {})
}
const invalidPath = { toString: () => '/repo' }
const resultPromise = getStatusOp(relayGit, relayStreamGit, {
worktreePath: invalidPath
})
const rejection = expect(resultPromise).rejects.toThrow(TypeError)
await statusStarted.promise
await rejection
})
it('keeps detector errors ahead of concurrent status failures', async () => {
const statusError = new Error('status failed too')
const relayStreamGit = (() => {
throw statusError
}) as RelayGitStreamExec
const invalidPath = { toString: () => '/repo' }
const result = getStatusOp(relayGit, relayStreamGit, { worktreePath: invalidPath })
await expect(result).rejects.toBeInstanceOf(TypeError)
})
})
describeBench('git status conflict-read overlap benchmark', () => {
it('measures native and relay orchestration with matched independent latency', async () => {
readFileMock.mockImplementation(async () => {
await wait(BENCH_DELAY_MS)
return 'gitdir: /repo/.git/worktrees/feature\n'
})
gitStreamStdoutMock.mockImplementation(async () => {
await wait(BENCH_DELAY_MS)
return { stoppedEarly: false }
})
const relayGit: GitExec = async () => ({ stdout: '', stderr: '' })
const relayStreamGit: RelayGitStreamExec = async () => {
await wait(BENCH_DELAY_MS)
return { stoppedEarly: false }
}
const cases = [
{ name: 'native', run: () => getStatus('/repo') },
{
name: 'relay',
run: () => getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' })
}
]
const results: BenchmarkResult[] = []
for (const benchmarkCase of cases) {
for (let index = 0; index < BENCH_WARMUPS; index += 1) {
await benchmarkCase.run()
}
const samples: number[] = []
for (let index = 0; index < BENCH_SAMPLES; index += 1) {
const startedAt = performance.now()
await benchmarkCase.run()
samples.push(performance.now() - startedAt)
}
results.push({
path: benchmarkCase.name,
medianMs: Number(percentile(samples, 0.5).toFixed(3)),
p95Ms: Number(percentile(samples, 0.95).toFixed(3)),
minMs: Number(Math.min(...samples).toFixed(3)),
maxMs: Number(Math.max(...samples).toFixed(3))
})
}
console.table(results)
expect(results).toHaveLength(2)
}, 10_000)
})
+22 -12
View File
@@ -315,22 +315,32 @@ async function runGetStatus(
// Why: stream + parse and stop at `limit` so a huge un-ignored folder can't buffer enough to crash the process.
const parser = new StatusPorcelainParser()
let didHitLimit = false
// Why: attach rejection ownership before awaiting marker I/O, so a fast Git failure cannot become unhandled.
const statusSettlementPromise = Promise.allSettled([
(async () => {
const result = await gitStreamStdout(statusArgs, {
cwd: worktreePath,
wslDistro: options.wslDistro,
preferWslDirectGit: true,
// Why: status polling is read-like; disable optional locks to avoid racing terminal Git on index.lock.
env: gitOptionalLocksDisabledEnv(),
signal: options.signal,
onStdout: (chunk) => parser.update(chunk, limit)
})
if (!result.stoppedEarly) {
parser.finish()
}
return result
})()
])
const conflictOperation = await conflictPromise
try {
const { stoppedEarly } = await gitStreamStdout(statusArgs, {
cwd: worktreePath,
wslDistro: options.wslDistro,
preferWslDirectGit: true,
// Why: status polling is read-like; disable optional locks to avoid racing terminal Git on index.lock.
env: gitOptionalLocksDisabledEnv(),
signal: options.signal,
onStdout: (chunk) => parser.update(chunk, limit)
})
if (!stoppedEarly) {
parser.finish()
const [statusResult] = await statusSettlementPromise
if (statusResult.status === 'rejected') {
throw statusResult.reason
}
didHitLimit = stoppedEarly
didHitLimit = statusResult.value.stoppedEarly
statusSucceeded = true
} catch (error) {
// Why: an aborted scan must reject, not resolve as an empty result.
+63 -62
View File
@@ -30,7 +30,7 @@ import {
isUnsupportedRevParsePathFormatError,
isUnsupportedWorktreeListZError
} from '../../shared/git-worktree-command-capabilities'
import { getLocalGitCapabilityCache } from './git-capability-state'
import { withLocalGitCapabilityCacheForExecution } from './git-capability-state'
import { gitExecFileAsync, translateWslOutputPaths } from './runner'
import { resolveGitDir, runWithGitReadCacheInvalidation } from './status'
import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
@@ -410,32 +410,32 @@ async function readRepoLocation(
resolveBasePath: string,
options: GitWorktreeExecOptions = {}
): Promise<RepoLocation | undefined> {
const capabilities = getLocalGitCapabilityCache({
cwd: repoPath,
wslDistro: options.wslDistro
})
try {
return await capabilities.runWithFallback(
'rev-parse-path-format',
async () => {
const { stdout } = await gitExecFileAsync(
['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'],
gitExecOptions(repoPath, options)
return await withLocalGitCapabilityCacheForExecution(
{ cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal },
(capabilities) =>
capabilities.runWithFallback(
'rev-parse-path-format',
async () => {
const { stdout } = await gitExecFileAsync(
['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'],
gitExecOptions(repoPath, options)
)
if (hasUnsupportedRevParsePathFormatEcho(stdout)) {
// Why: some old Git echoes the unknown option and exits zero; remember that compat signal even though parsing recovers.
capabilities.rememberUnsupported('rev-parse-path-format')
}
return parseRepoLocation(resolveBasePath, stdout)
},
async () => {
const { stdout } = await gitExecFileAsync(
['rev-parse', '--show-toplevel', '--git-common-dir'],
gitExecOptions(repoPath, options)
)
return parseRepoLocation(resolveBasePath, stdout)
},
isUnsupportedRevParsePathFormatError
)
if (hasUnsupportedRevParsePathFormatEcho(stdout)) {
// Why: some old Git echoes the unknown option and exits zero; remember that compat signal even though parsing recovers.
capabilities.rememberUnsupported('rev-parse-path-format')
}
return parseRepoLocation(resolveBasePath, stdout)
},
async () => {
const { stdout } = await gitExecFileAsync(
['rev-parse', '--show-toplevel', '--git-common-dir'],
gitExecOptions(repoPath, options)
)
return parseRepoLocation(resolveBasePath, stdout)
},
isUnsupportedRevParsePathFormatError
)
} catch {
return undefined
@@ -578,41 +578,44 @@ async function readWorktreeList(
repoPath: string,
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
const capabilities = getLocalGitCapabilityCache({
cwd: repoPath,
wslDistro: options.wslDistro
})
const execOptions = {
cwd: repoPath,
...options,
timeout: options.timeout ?? WORKTREE_LIST_TIMEOUT_MS
}
return capabilities.runWithFallback(
'worktree-list-z',
async () => {
const { stdout } = await gitExecFileAsync(
['worktree', 'list', '--porcelain', '-z'],
execOptions
return withLocalGitCapabilityCacheForExecution(
{ cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal },
(capabilities) =>
capabilities.runWithFallback(
'worktree-list-z',
async () => {
const { stdout } = await gitExecFileAsync(
['worktree', 'list', '--porcelain', '-z'],
execOptions
)
return normalizeMainWorktreePath(
repoPath,
parseWorktreeList(stdout, { nulDelimited: true }),
options
)
},
async () => {
// Why: `-z` preserves worktree paths with newlines but Git <2.36 rejects it; fall back to the line parser.
const { stdout } = await gitExecFileAsync(
['worktree', 'list', '--porcelain'],
execOptions
)
const normalized = await normalizeMainWorktreePath(
repoPath,
parseWorktreeList(stdout),
options
)
// Why: Git <2.31 emits no `prunable`, so probe each linked path for existence instead of trusting
// stale registrations; a harmless backstop on 2.31–2.35 where parseWorktreeList already set it (#8389).
return annotatePrunableByExistence(normalized, repoPath, options)
},
isUnsupportedWorktreeListZError
)
return normalizeMainWorktreePath(
repoPath,
parseWorktreeList(stdout, { nulDelimited: true }),
options
)
},
async () => {
// Why: `-z` preserves worktree paths with newlines but Git <2.36 rejects it; fall back to the line parser.
const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain'], execOptions)
const normalized = await normalizeMainWorktreePath(
repoPath,
parseWorktreeList(stdout),
options
)
// Why: Git <2.31 emits no `prunable`, so probe each linked path for existence instead of trusting
// stale registrations; a harmless backstop on 2.31–2.35 where parseWorktreeList already set it (#8389).
return annotatePrunableByExistence(normalized, repoPath, options)
},
isUnsupportedWorktreeListZError
)
}
@@ -1355,14 +1358,12 @@ async function deleteAlreadyMergedBranchAfterSafeDeleteFailure(
})
const targetRefs = await getBranchCleanupTargetRefs(runGit, branchName)
// Why: squash merges rewrite commit IDs, so `branch -d` rejects already-merged branches; delete only when Git proves no unmerged tree changes.
if (
!(await branchHasNoUnmergedChangesWithLazyTargetRefresh(
runGit,
branchName,
targetRefs,
getLocalGitCapabilityCache({ cwd: repoPath, wslDistro: options.wslDistro })
))
) {
const hasNoUnmergedChanges = await withLocalGitCapabilityCacheForExecution(
{ cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal },
(capabilities) =>
branchHasNoUnmergedChangesWithLazyTargetRefresh(runGit, branchName, targetRefs, capabilities)
)
if (!hasNoUnmergedChanges) {
return false
}
await forceDeleteLocalBranch(repoPath, branchName, branchHead, (args, cwd) =>

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